diff --git a/src/container.ts b/src/container.ts index fe16296771d6c..0b73282ff8d5e 100644 --- a/src/container.ts +++ b/src/container.ts @@ -32,6 +32,7 @@ import { ServerConnection } from './plus/gk/serverConnection'; import { SubscriptionService } from './plus/gk/subscriptionService'; import { GraphStatusBarController } from './plus/graph/statusbar'; import type { CloudIntegrationService } from './plus/integrations/authentication/cloudIntegrationService'; +import { ConfiguredIntegrationService } from './plus/integrations/authentication/configuredIntegrationService'; import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthenticationService'; import { IntegrationService } from './plus/integrations/integrationService'; import type { GitHubApi } from './plus/integrations/providers/github/github'; @@ -532,8 +533,12 @@ export class Container { private _integrations: IntegrationService | undefined; get integrations(): IntegrationService { if (this._integrations == null) { - const authService = new IntegrationAuthenticationService(this); - this._disposables.push(authService, (this._integrations = new IntegrationService(this, authService))); + const configuredIntegrationService = new ConfiguredIntegrationService(this); + const authService = new IntegrationAuthenticationService(this, configuredIntegrationService); + this._disposables.push( + authService, + (this._integrations = new IntegrationService(this, authService, configuredIntegrationService)), + ); } return this._integrations; } diff --git a/src/env/node/git/sub-providers/remotes.ts b/src/env/node/git/sub-providers/remotes.ts index 37efc369fc1ce..2915eaa3679bf 100644 --- a/src/env/node/git/sub-providers/remotes.ts +++ b/src/env/node/git/sub-providers/remotes.ts @@ -40,7 +40,7 @@ export class RemotesGitSubProvider extends RemotesGitProviderBase implements Git async function load(this: RemotesGitSubProvider): Promise { const providers = loadRemoteProviders( configuration.get('remotes', this.container.git.getRepository(repoPath!)?.folder?.uri ?? null), - this.container.integrations.getConfiguredIntegrationDescriptors(), + await this.container.integrations.getConfigured(), ); try { @@ -49,7 +49,7 @@ export class RemotesGitSubProvider extends RemotesGitProviderBase implements Git this.container, data, repoPath!, - getRemoteProviderMatcher(this.container, providers), + await getRemoteProviderMatcher(this.container, providers), ); return remotes; } catch (ex) { diff --git a/src/git/parsers/remoteParser.ts b/src/git/parsers/remoteParser.ts index 9377b544320e6..4a25b7ef85fa5 100644 --- a/src/git/parsers/remoteParser.ts +++ b/src/git/parsers/remoteParser.ts @@ -10,7 +10,7 @@ export function parseGitRemotes( container: Container, data: string, repoPath: string, - remoteProviderMatcher: ReturnType, + remoteProviderMatcher: Awaited>, ): GitRemote[] { using sw = maybeStopWatch(`Git.parseRemotes(${repoPath})`, { log: false, logLevel: 'debug' }); if (!data) return []; diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index 09fe2c2d2392c..6067e9b2ff1c5 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -167,14 +167,14 @@ function getCustomProviderCreator(cfg: RemotesConfig) { } } -export function getRemoteProviderMatcher( +export async function getRemoteProviderMatcher( container: Container, providers?: RemoteProviders, -): (url: string, domain: string, path: string) => RemoteProvider | undefined { +): Promise<(url: string, domain: string, path: string) => RemoteProvider | undefined> { if (providers == null) { providers = loadRemoteProviders( configuration.get('remotes', null), - container.integrations.getConfiguredIntegrationDescriptors(), + await container.integrations.getConfigured(), ); } diff --git a/src/plus/drafts/draftsService.ts b/src/plus/drafts/draftsService.ts index a3402e7d49382..c9ef8475541af 100644 --- a/src/plus/drafts/draftsService.ts +++ b/src/plus/drafts/draftsService.ts @@ -729,7 +729,7 @@ export class DraftService implements Disposable { } else if (data.provider?.repoName != null) { name = data.provider.repoName; } else if (data.remote?.url != null && data.remote?.domain != null && data.remote?.path != null) { - const matcher = getRemoteProviderMatcher(this.container); + const matcher = await getRemoteProviderMatcher(this.container); const provider = matcher(data.remote.url, data.remote.domain, data.remote.path); name = provider?.repoName ?? data.remote.path; } else { diff --git a/src/plus/gk/utils/-webview/integrationAuthentication.utils.ts b/src/plus/gk/utils/-webview/integrationAuthentication.utils.ts index fa302fdf87335..b9d9528d63258 100644 --- a/src/plus/gk/utils/-webview/integrationAuthentication.utils.ts +++ b/src/plus/gk/utils/-webview/integrationAuthentication.utils.ts @@ -29,6 +29,7 @@ export const getBuiltInIntegrationSession = sequentialize( return { ...session, cloud: false, + domain: descriptor.domain, }; }, ), diff --git a/src/plus/integrations/authentication/azureDevOps.ts b/src/plus/integrations/authentication/azureDevOps.ts index d4db11b71d4b4..0bfd1794c28a5 100644 --- a/src/plus/integrations/authentication/azureDevOps.ts +++ b/src/plus/integrations/authentication/azureDevOps.ts @@ -12,9 +12,9 @@ export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthentic } override async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise { - let azureOrganization: string | undefined = descriptor?.organization as string | undefined; + let azureOrganization: string | undefined = descriptor.organization as string | undefined; if (!azureOrganization) { const orgInput = window.createInputBox(); orgInput.ignoreFocusOut = true; @@ -35,9 +35,7 @@ export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthentic }), ); - orgInput.title = `Azure DevOps Authentication${ - descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' - }`; + orgInput.title = `Azure DevOps Authentication \u2022 ${descriptor.domain}`; orgInput.placeholder = 'Organization'; orgInput.prompt = 'Enter your Azure DevOps organization'; orgInput.show(); @@ -78,24 +76,16 @@ export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthentic tokenInput.onDidTriggerButton(e => { if (e === infoButton) { void env.openExternal( - Uri.parse( - `https://${ - descriptor?.domain ?? 'dev.azure.com' - }/${azureOrganization}/_usersSettings/tokens`, - ), + Uri.parse(`https://${descriptor.domain}/${azureOrganization}/_usersSettings/tokens`), ); } }), ); tokenInput.password = true; - tokenInput.title = `Azure DevOps Authentication${ - descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' - }`; - tokenInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - tokenInput.prompt = `Paste your [Azure DevOps Personal Access Token](https://${ - descriptor?.domain ?? 'dev.azure.com' - }/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")`; + tokenInput.title = `Azure DevOps Authentication \u2022 ${descriptor.domain}`; + tokenInput.placeholder = `Requires ${descriptor.scopes.join(', ') ?? 'all'} scopes`; + tokenInput.prompt = `Paste your [Azure DevOps Personal Access Token](https://${descriptor.domain}/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")`; tokenInput.buttons = [infoButton]; tokenInput.show(); @@ -108,14 +98,15 @@ export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthentic if (!token) return undefined; return { - id: this.getSessionId(descriptor), + id: this.configuredIntegrationService.getSessionId(descriptor), accessToken: base64(`:${token}`), - scopes: descriptor?.scopes ?? [], + scopes: descriptor.scopes, account: { id: '', label: '', }, cloud: false, + domain: descriptor.domain, }; } } diff --git a/src/plus/integrations/authentication/bitbucket.ts b/src/plus/integrations/authentication/bitbucket.ts index bce7737a49626..bf652ff0ab1e4 100644 --- a/src/plus/integrations/authentication/bitbucket.ts +++ b/src/plus/integrations/authentication/bitbucket.ts @@ -12,9 +12,9 @@ export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticat } override async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise { - let bitbucketUsername: string | undefined = descriptor?.username as string | undefined; + let bitbucketUsername: string | undefined = descriptor.username as string | undefined; if (!bitbucketUsername) { const infoButton: QuickInputButton = { iconPath: new ThemeIcon(`link-external`), @@ -40,20 +40,14 @@ export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticat }), usernameInput.onDidTriggerButton(e => { if (e === infoButton) { - void env.openExternal( - Uri.parse(`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/`), - ); + void env.openExternal(Uri.parse(`https://${descriptor.domain}/account/settings/`)); } }), ); - usernameInput.title = `Bitbucket Authentication${ - descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' - }`; + usernameInput.title = `Bitbucket Authentication \u2022 ${descriptor.domain}`; usernameInput.placeholder = 'Username'; - usernameInput.prompt = `Enter your [Bitbucket Username](https://${ - descriptor?.domain ?? 'bitbucket.org' - }/account/settings/ "Get your Bitbucket App Password")`; + usernameInput.prompt = `Enter your [Bitbucket Username](https://${descriptor.domain}/account/settings/ "Get your Bitbucket App Password")`; usernameInput.show(); }); } finally { @@ -92,22 +86,16 @@ export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticat appPasswordInput.onDidTriggerButton(e => { if (e === infoButton) { void env.openExternal( - Uri.parse( - `https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/app-passwords/`, - ), + Uri.parse(`https://${descriptor.domain}/account/settings/app-passwords/`), ); } }), ); appPasswordInput.password = true; - appPasswordInput.title = `Bitbucket Authentication${ - descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' - }`; - appPasswordInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - appPasswordInput.prompt = `Paste your [Bitbucket App Password](https://${ - descriptor?.domain ?? 'bitbucket.org' - }/account/settings/app-passwords/ "Get your Bitbucket App Password")`; + appPasswordInput.title = `Bitbucket Authentication \u2022 ${descriptor.domain}`; + appPasswordInput.placeholder = `Requires ${descriptor.scopes.join(', ')} scopes`; + appPasswordInput.prompt = `Paste your [Bitbucket App Password](https://${descriptor.domain}/account/settings/app-passwords/ "Get your Bitbucket App Password")`; appPasswordInput.buttons = [infoButton]; appPasswordInput.show(); @@ -120,14 +108,15 @@ export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticat if (!appPassword) return undefined; return { - id: this.getSessionId(descriptor), + id: this.configuredIntegrationService.getSessionId(descriptor), accessToken: base64(`${bitbucketUsername}:${appPassword}`), - scopes: descriptor?.scopes ?? [], + scopes: descriptor.scopes, account: { id: '', label: '', }, cloud: false, + domain: descriptor.domain, }; } } diff --git a/src/plus/integrations/authentication/configuredIntegrationService.ts b/src/plus/integrations/authentication/configuredIntegrationService.ts new file mode 100644 index 0000000000000..7fd3b39b6c761 --- /dev/null +++ b/src/plus/integrations/authentication/configuredIntegrationService.ts @@ -0,0 +1,370 @@ +import type { IntegrationId } from '../../../constants.integrations'; +import { HostingIntegrationId } from '../../../constants.integrations'; +import type { StoredConfiguredIntegrationDescriptor } from '../../../constants.storage'; +import type { Container } from '../../../container'; +import { flatten } from '../../../system/iterable'; +import { getBuiltInIntegrationSession } from '../../gk/utils/-webview/integrationAuthentication.utils'; +import { isSelfHostedIntegrationId, providersMetadata } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthenticationProvider'; +import type { ConfiguredIntegrationDescriptor, ProviderAuthenticationSession } from './models'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; + cloud?: boolean; + expiresAt?: string; + domain?: string; +} + +export type ConfiguredIntegrationType = 'cloud' | 'local'; + +export class ConfiguredIntegrationService { + private _configured?: Map; + + constructor(private readonly container: Container) {} + + private get configured(): Map { + if (this._configured == null) { + this._configured = new Map(); + const storedConfigured = this.container.storage.get('integrations:configured'); + for (const [id, configured] of Object.entries(storedConfigured ?? {})) { + if (configured == null) continue; + const descriptors = configured.map(d => ({ + ...d, + expiresAt: d.expiresAt ? new Date(d.expiresAt) : undefined, + })); + this._configured.set(id as IntegrationId, descriptors); + } + } + + return this._configured; + } + + // async because we do the heavy work of checking authentication api for your vscode GitHub session + async getConfigured(options?: { + id?: IntegrationId; + domain?: string; + type?: ConfiguredIntegrationType; + }): Promise { + const descriptors: ConfiguredIntegrationDescriptor[] = []; + const configured = + options?.id != null + ? this.configured.get(options.id) + : [...flatten(this.configured.values())]; + + if (configured != null && (options?.domain != null || options?.type != null)) { + for (const descriptor of configured) { + if (options?.domain != null && descriptor.domain !== options.domain) continue; + if (options?.type === 'cloud' && !descriptor.cloud) continue; + if (options?.type === 'local' && descriptor.cloud) continue; + descriptors.push(descriptor); + } + } else { + descriptors.push(...(configured ?? [])); + } + + // If we don't have a cloud config for GitHub, include a descriptor for the built-in VS Code session of GitHub even though we don't store it + if ( + (options?.id == null || options.id === HostingIntegrationId.GitHub) && + options?.type !== 'cloud' && + !this.configured.get(HostingIntegrationId.GitHub) + ) { + const vscodeSession = await getBuiltInIntegrationSession( + this.container, + HostingIntegrationId.GitHub, + { + domain: providersMetadata[HostingIntegrationId.GitHub].domain, + scopes: providersMetadata[HostingIntegrationId.GitHub].scopes, + }, + { silent: true }, + ); + + if (vscodeSession != null) { + descriptors.push({ + integrationId: HostingIntegrationId.GitHub, + domain: undefined, + expiresAt: vscodeSession.expiresAt, + scopes: providersMetadata[HostingIntegrationId.GitHub].scopes.join(','), + cloud: false, + }); + } + } + + return descriptors; + } + + // getConfigured without the async check for the GitHub vscode session (which forces async and is a db hit) + getConfiguredLite(options?: { + id?: IntegrationId; + domain?: string; + type?: ConfiguredIntegrationType; + }): ConfiguredIntegrationDescriptor[] { + const descriptors: ConfiguredIntegrationDescriptor[] = []; + + const configured = + options?.id != null + ? this.configured.get(options.id) + : [...flatten(this.configured.values())]; + if (configured == null) return descriptors; + + if (options?.domain != null || options?.type != null) { + for (const descriptor of configured) { + if (options?.domain != null && descriptor.domain !== options.domain) continue; + if (options?.type === 'cloud' && !descriptor.cloud) continue; + if (options?.type === 'local' && descriptor.cloud) continue; + descriptors.push(descriptor); + } + } else { + descriptors.push(...configured); + } + + return descriptors; + } + + private async storeConfigured(): Promise { + // We need to convert the map to a record to store + const configured: Record = {}; + for (const [id, descriptors] of this.configured) { + configured[id] = descriptors.map(d => ({ + ...d, + expiresAt: d.expiresAt + ? d.expiresAt instanceof Date + ? d.expiresAt.toISOString() + : d.expiresAt + : undefined, + })); + } + + await this.container.storage.store('integrations:configured', configured); + } + + private async addConfigured(descriptor: ConfiguredIntegrationDescriptor): Promise { + const descriptors = this.configured.get(descriptor.integrationId) ?? []; + const existing = descriptors.find( + d => + d.domain === descriptor.domain && + d.integrationId === descriptor.integrationId && + d.cloud === descriptor.cloud, + ); + + if (existing != null) { + if (existing.expiresAt === descriptor.expiresAt && existing.scopes === descriptor.scopes) { + return; + } + + //remove the existing descriptor from the array + const index = descriptors.indexOf(existing); + descriptors.splice(index, 1); + } + + descriptors.push(descriptor); + this.configured.set(descriptor.integrationId, descriptors); + await this.storeConfigured(); + } + + private async removeConfigured( + id: IntegrationId, + options?: { domain?: string; type?: ConfiguredIntegrationType }, + ): Promise { + const descriptors = this.configured + .get(id) + ?.filter(d => + options?.type === 'cloud' + ? !(d.cloud === true && d.domain === options?.domain) + : options?.type === 'local' + ? !(d.cloud === false && d.domain === options?.domain) + : d.domain !== options?.domain, + ); + + if (descriptors != null && descriptors.length === 0) { + this.configured.delete(id); + } + + this.configured.set(id, descriptors ?? []); + await this.storeConfigured(); + } + + async storeSession(id: IntegrationId, session: ProviderAuthenticationSession): Promise { + await this.writeSecret(id, session); + } + + async getStoredSession( + id: IntegrationId, + descriptor: IntegrationAuthenticationSessionDescriptor, + type: ConfiguredIntegrationType = 'local', + ): Promise { + const sessionId = this.getSessionId(descriptor); + let session = await this.readSecret(id, sessionId, 'local'); + if (type !== 'cloud') return convertStoredSessionToSession(session, descriptor, false); + + let cloudIfMissing = false; + if (session != null) { + // Check the `expiresAt` field + // If it has an expiresAt property and the key is the old type, then it's a cloud session, + // so delete it from the local key and + // store with the "cloud" type key, and then use that one. + // Otherwise it's a local session under the local key, so just return it. + if (session.expiresAt != null) { + cloudIfMissing = true; + await Promise.allSettled([this.deleteSecrets(id, session.id), this.writeSecret(id, session)]); + } + } + + // If no local session we try to restore a session with the cloud key + if (session == null) { + cloudIfMissing = true; + session = await this.readSecret(id, sessionId, 'cloud'); + } + + return convertStoredSessionToSession(session, descriptor, cloudIfMissing); + } + + async deleteStoredSessions( + id: IntegrationId, + descriptor: IntegrationAuthenticationSessionDescriptor, + type?: ConfiguredIntegrationType, + ): Promise { + await this.deleteSecrets(id, this.getSessionId(descriptor), type); + } + + async deleteAllStoredSessions(id: IntegrationId, type?: ConfiguredIntegrationType): Promise { + await this.deleteAllSecrets(id, type); + } + + async deleteSecrets(id: IntegrationId, sessionId: string, type?: ConfiguredIntegrationType): Promise { + if (type == null || type === 'local') { + await this.container.storage.deleteSecret(this.getLocalSecretKey(id, sessionId)); + } + + if (type == null || type === 'cloud') { + await this.container.storage.deleteSecret(this.getCloudSecretKey(id, sessionId)); + } + + await this.removeConfigured(id, { + domain: isSelfHostedIntegrationId(id) ? sessionId : undefined, + type: type, + }); + } + + async deleteAllSecrets(id: IntegrationId, type?: ConfiguredIntegrationType): Promise { + if (isSelfHostedIntegrationId(id)) { + // Hack because session IDs are tied to domain. Update this when session ids are different + const configuredDomains = this.configured.get(id)?.map(c => c.domain); + if (configuredDomains != null) { + for (const domain of configuredDomains) { + await this.deleteSecrets(id, domain!, type); + } + } + + return; + } + + await this.deleteSecrets(id, providersMetadata[id].domain, type); + } + + async writeSecret(id: IntegrationId, session: ProviderAuthenticationSession | StoredSession): Promise { + await this.container.storage.storeSecret( + this.getSecretKey(id, session.id, session.cloud ? 'cloud' : 'local'), + JSON.stringify(session), + ); + + await this.addConfigured({ + integrationId: id, + domain: isSelfHostedIntegrationId(id) ? session.domain : undefined, + expiresAt: session.expiresAt, + scopes: session.scopes.join(','), + cloud: session.cloud ?? false, + }); + } + + async readSecret( + id: IntegrationId, + sessionId: string, + type: ConfiguredIntegrationType = 'local', + ): Promise { + let storedSession: StoredSession | undefined; + try { + const sessionJSON = await this.container.storage.getSecret(this.getSecretKey(id, sessionId, type)); + if (sessionJSON) { + storedSession = JSON.parse(sessionJSON); + if (storedSession != null) { + const configured = this.configured.get(id); + const domain = isSelfHostedIntegrationId(id) ? storedSession.id : undefined; + if ( + configured == null || + configured.length === 0 || + !configured.some(c => c.domain === domain && c.integrationId === id) + ) { + await this.addConfigured({ + integrationId: id, + domain: domain, + expiresAt: storedSession.expiresAt, + scopes: storedSession.scopes.join(','), + cloud: storedSession.cloud ?? false, + }); + } + } + } + } catch (_ex) { + try { + await this.deleteSecrets(id, sessionId, type); + } catch {} + } + return storedSession; + } + + private getSecretKey( + id: IntegrationId, + sessionId: string, + type: ConfiguredIntegrationType = 'local', + ): + | `gitlens.integration.auth:${IntegrationId}|${string}` + | `gitlens.integration.auth.cloud:${IntegrationId}|${string}` { + return type === 'cloud' ? this.getCloudSecretKey(id, sessionId) : this.getLocalSecretKey(id, sessionId); + } + + private getLocalSecretKey( + id: IntegrationId, + sessionId: string, + ): `gitlens.integration.auth:${IntegrationId}|${string}` { + return `gitlens.integration.auth:${id}|${sessionId}`; + } + + private getCloudSecretKey( + id: IntegrationId, + sessionId: string, + ): `gitlens.integration.auth.cloud:${IntegrationId}|${string}` { + return `gitlens.integration.auth.cloud:${id}|${sessionId}`; + } + + getSessionId(descriptor: IntegrationAuthenticationSessionDescriptor): string { + return descriptor.domain; + } +} + +function convertStoredSessionToSession( + storedSession: StoredSession | undefined, + descriptor: IntegrationAuthenticationSessionDescriptor, + cloudIfMissing: boolean, +): ProviderAuthenticationSession | undefined { + if (storedSession == null) return undefined; + + return { + id: storedSession.id, + accessToken: storedSession.accessToken, + account: { + id: storedSession.account?.id ?? '', + label: storedSession.account?.label ?? '', + }, + scopes: storedSession.scopes, + cloud: storedSession.cloud ?? cloudIfMissing, + expiresAt: storedSession.expiresAt ? new Date(storedSession.expiresAt) : undefined, + domain: storedSession.domain ?? descriptor.domain, + }; +} diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts index d4109de3c6232..dc711f8abfa62 100644 --- a/src/plus/integrations/authentication/github.ts +++ b/src/plus/integrations/authentication/github.ts @@ -4,6 +4,7 @@ import { wrapForForcedInsecureSSL } from '@env/fetch'; import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Sources } from '../../../constants.telemetry'; import type { Container } from '../../../container'; +import type { ConfiguredIntegrationService } from './configuredIntegrationService'; import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthenticationProvider'; import { CloudIntegrationAuthenticationProvider, @@ -13,8 +14,12 @@ import type { IntegrationAuthenticationService } from './integrationAuthenticati import type { ProviderAuthenticationSession } from './models'; export class GitHubAuthenticationProvider extends CloudIntegrationAuthenticationProvider { - constructor(container: Container, authenticationService: IntegrationAuthenticationService) { - super(container, authenticationService); + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + configuredIntegrationService: ConfiguredIntegrationService, + ) { + super(container, authenticationService, configuredIntegrationService); this.disposables.push( authentication.onDidChangeSessions(e => { if (e.provider.id === this.authProviderId) { @@ -29,11 +34,9 @@ export class GitHubAuthenticationProvider extends CloudIntegrationAuthentication } private async getBuiltInExistingSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, forceNewSession?: boolean, ): Promise { - if (descriptor == null) return undefined; - return wrapForForcedInsecureSSL( this.container.integrations.ignoreSSLErrors({ id: this.authProviderId, domain: descriptor?.domain }), async () => { @@ -45,13 +48,14 @@ export class GitHubAuthenticationProvider extends CloudIntegrationAuthentication return { ...session, cloud: false, + domain: descriptor.domain, }; }, ); } public override async getSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, ): Promise { let vscodeSession = await this.getBuiltInExistingSession(descriptor); @@ -64,17 +68,9 @@ export class GitHubAuthenticationProvider extends CloudIntegrationAuthentication return super.getSession(descriptor, options); } - - protected override getCompletionInputTitle(): string { - return 'Connect to GitHub'; - } } export class GitHubEnterpriseCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { - protected override getCompletionInputTitle(): string { - throw new Error('Connect to GitHub Enterprise'); - } - protected override get authProviderId(): SelfHostedIntegrationId.CloudGitHubEnterprise { return SelfHostedIntegrationId.CloudGitHubEnterprise; } @@ -86,7 +82,7 @@ export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuth } override async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise { const input = window.createInputBox(); input.ignoreFocusOut = true; @@ -115,19 +111,15 @@ export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuth }), input.onDidTriggerButton(e => { if (e === infoButton) { - void env.openExternal( - Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), - ); + void env.openExternal(Uri.parse(`https://${descriptor.domain}/settings/tokens`)); } }), ); input.password = true; - input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires a classic token with ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = `Paste your [GitHub Personal Access Token](https://${ - descriptor?.domain ?? 'github.com' - }/settings/tokens "Get your GitHub Access Token")`; + input.title = `GitHub Authentication \u2022 ${descriptor.domain}`; + input.placeholder = `Requires a classic token with ${descriptor.scopes.join(', ')} scopes`; + input.prompt = `Paste your [GitHub Personal Access Token](https://${descriptor.domain}/settings/tokens "Get your GitHub Access Token")`; input.buttons = [infoButton]; @@ -141,7 +133,7 @@ export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuth if (!token) return undefined; return { - id: this.getSessionId(descriptor), + id: this.configuredIntegrationService.getSessionId(descriptor), accessToken: token, scopes: descriptor?.scopes ?? [], account: { @@ -149,6 +141,7 @@ export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuth label: '', }, cloud: false, + domain: descriptor.domain, }; } } diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts index f5a9df71a5bc3..849cc693dc1dd 100644 --- a/src/plus/integrations/authentication/gitlab.ts +++ b/src/plus/integrations/authentication/gitlab.ts @@ -2,6 +2,7 @@ import type { Disposable, QuickInputButton } from 'vscode'; import { env, ThemeIcon, Uri, window } from 'vscode'; import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Container } from '../../../container'; +import type { ConfiguredIntegrationService } from './configuredIntegrationService'; import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthenticationProvider'; import { CloudIntegrationAuthenticationProvider, @@ -16,13 +17,14 @@ export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthentic constructor( container: Container, authenticationService: IntegrationAuthenticationService, + configuredIntegrationService: ConfiguredIntegrationService, protected readonly authProviderId: GitLabId, ) { - super(container, authenticationService); + super(container, authenticationService, configuredIntegrationService); } override async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise { const input = window.createInputBox(); input.ignoreFocusOut = true; @@ -52,22 +54,20 @@ export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthentic input.onDidTriggerButton(e => { if (e === infoButton) { void env.openExternal( - Uri.parse( - `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, - ), + Uri.parse(`https://${descriptor.domain}/-/profile/personal_access_tokens`), ); } }), ); input.password = true; - input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.title = `GitLab Authentication \u2022 ${descriptor.domain}`; + input.placeholder = `Requires ${descriptor.scopes.join(', ')} scopes`; input.prompt = `Paste your [GitLab Personal Access Token](https://${ - descriptor?.domain ?? 'gitlab.com' - }/-/user_settings/personal_access_tokens?name=GitLens+Access+token&scopes=${ - descriptor?.scopes.join(',') ?? 'all' - } "Get your GitLab Access Token")`; + descriptor.domain + }/-/user_settings/personal_access_tokens?name=GitLens+Access+token&scopes=${descriptor.scopes.join( + ',', + )} "Get your GitLab Access Token")`; input.buttons = [infoButton]; input.show(); @@ -80,7 +80,7 @@ export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthentic if (!token) return undefined; return { - id: this.getSessionId(descriptor), + id: this.configuredIntegrationService.getSessionId(descriptor), accessToken: token, scopes: descriptor?.scopes ?? [], account: { @@ -88,15 +88,12 @@ export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthentic label: '', }, cloud: false, + domain: descriptor.domain, }; } } export class GitLabSelfHostedCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { - protected override getCompletionInputTitle(): string { - throw new Error('Connect to GitLab Enterprise'); - } - protected override get authProviderId(): SelfHostedIntegrationId.CloudGitLabSelfHosted { return SelfHostedIntegrationId.CloudGitLabSelfHosted; } @@ -106,8 +103,4 @@ export class GitLabCloudAuthenticationProvider extends CloudIntegrationAuthentic protected override get authProviderId(): GitLabId { return HostingIntegrationId.GitLab; } - - protected override getCompletionInputTitle(): string { - return 'Connect to GitLab'; - } } diff --git a/src/plus/integrations/authentication/integrationAuthenticationProvider.ts b/src/plus/integrations/authentication/integrationAuthenticationProvider.ts index 7cd0ef13ae80b..66bd247382ee1 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationProvider.ts @@ -1,33 +1,19 @@ -import type { CancellationToken, Disposable, Event, Uri } from 'vscode'; -import { authentication, EventEmitter, window } from 'vscode'; +import type { Disposable, Event } from 'vscode'; +import { authentication, EventEmitter } from 'vscode'; import type { IntegrationId } from '../../../constants.integrations'; import { HostingIntegrationId } from '../../../constants.integrations'; -import type { IntegrationAuthenticationKeys } from '../../../constants.storage'; import type { Sources } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { debug } from '../../../system/decorators/log'; -import type { DeferredEventExecutor } from '../../../system/event'; import { getBuiltInIntegrationSession } from '../../gk/utils/-webview/integrationAuthentication.utils'; import { isCloudSelfHostedIntegrationId, isSelfHostedIntegrationId } from '../providers/models'; +import type { ConfiguredIntegrationService } from './configuredIntegrationService'; import type { IntegrationAuthenticationService } from './integrationAuthenticationService'; import type { ProviderAuthenticationSession } from './models'; import { isSupportedCloudIntegrationId } from './models'; const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; - cloud?: boolean; - expiresAt?: string; -} - export interface IntegrationAuthenticationProviderDescriptor { id: IntegrationId; scopes: string[]; @@ -40,9 +26,10 @@ export interface IntegrationAuthenticationSessionDescriptor { } export interface IntegrationAuthenticationProvider extends Disposable { - deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; + deleteSession(descriptor: IntegrationAuthenticationSessionDescriptor): Promise; + deleteAllSessions(): Promise; getSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, options?: | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources } | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources }, @@ -55,9 +42,12 @@ abstract class IntegrationAuthenticationProviderBase; - - protected abstract deleteAllSecrets(sessionId: string): Promise; - - protected abstract storeSession(sessionId: string, session: ProviderAuthenticationSession): Promise; - - protected abstract restoreSession(sessionId: string): Promise; - - protected async deleteSecret(key: IntegrationAuthenticationKeys, sessionId: string) { - await this.container.storage.deleteSecret(key); - await this.authenticationService.removeConfigured({ - integrationId: this.authProviderId, - domain: isSelfHostedIntegrationId(this.authProviderId) ? sessionId : undefined, - }); - } - - protected async writeSecret( - key: IntegrationAuthenticationKeys, - session: ProviderAuthenticationSession | StoredSession, - ) { - await this.container.storage.storeSecret(key, JSON.stringify(session)); - // TODO: we should add `domain` on to the session like we are doing with `cloud` to make it explicit - await this.authenticationService.addConfigured({ - integrationId: this.authProviderId, - domain: isSelfHostedIntegrationId(this.authProviderId) ? session.id : undefined, - expiresAt: session.expiresAt, - scopes: session.scopes.join(','), - cloud: session.cloud ?? false, + @debug() + async deleteSession(descriptor: IntegrationAuthenticationSessionDescriptor): Promise { + const configured = await this.configuredIntegrationService.getConfigured({ + id: this.authProviderId, + domain: isSelfHostedIntegrationId(this.authProviderId) ? descriptor?.domain : undefined, + type: this.cloud ? 'cloud' : 'local', }); - } - protected async readSecret( - key: IntegrationAuthenticationKeys, - sessionId: string, - ): Promise { - let storedSession: StoredSession | undefined; - try { - const sessionJSON = await this.container.storage.getSecret(key); - if (sessionJSON) { - storedSession = JSON.parse(sessionJSON); - if (storedSession != null) { - const configured = this.authenticationService.configured.get(this.authProviderId); - const domain = isSelfHostedIntegrationId(this.authProviderId) ? storedSession.id : undefined; - if ( - configured == null || - configured.length === 0 || - !configured.some(c => c.domain === domain && c.integrationId === this.authProviderId) - ) { - await this.authenticationService.addConfigured({ - integrationId: this.authProviderId, - domain: domain, - expiresAt: storedSession.expiresAt, - scopes: storedSession.scopes.join(','), - cloud: storedSession.cloud ?? false, - }); - } - } - } - } catch (_ex) { - try { - await this.deleteSecret(key, sessionId); - } catch {} + await this.configuredIntegrationService.deleteStoredSessions( + this.authProviderId, + descriptor, + this.cloud ? undefined : 'local', + ); + if (configured != null && configured.length > 0) { + this.fireDidChange(); } - return storedSession; - } - - protected getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - protected getLocalSecretKey(id: string): `gitlens.integration.auth:${IntegrationId}|${string}` { - return `gitlens.integration.auth:${this.authProviderId}|${id}`; } @debug() - async deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise { - const sessionId = this.getSessionId(descriptor); - const storedSession = await this.restoreSession(sessionId); - await this.deleteAllSecrets(sessionId); - if (storedSession != null) { + async deleteAllSessions(): Promise { + const configured = await this.configuredIntegrationService.getConfigured({ + id: this.authProviderId, + type: this.cloud ? 'cloud' : 'local', + }); + + await this.configuredIntegrationService.deleteAllStoredSessions( + this.authProviderId, + this.cloud ? undefined : 'local', + ); + if (configured != null && configured.length > 0) { this.fireDidChange(); } } @debug() async getSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, options?: | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources } | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources }, ): Promise { - const sessionId = this.getSessionId(descriptor); - let session; - let storedSession; + let previousToken; if (options?.forceNewSession) { - await this.deleteAllSecrets(sessionId); + await this.configuredIntegrationService.deleteStoredSessions( + this.authProviderId, + descriptor, + // Cloud auth providers delete both types, while local only delete their own + this.cloud ? undefined : 'local', + ); } else { - storedSession = await this.restoreSession(sessionId); - session = storedSession; + session = await this.configuredIntegrationService.getStoredSession( + this.authProviderId, + descriptor, + this.cloud ? 'cloud' : 'local', + ); + previousToken = session?.accessToken; } const isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now(); if (session == null || isExpiredSession) { - session = await this.fetchOrCreateSession(storedSession, descriptor, { - ...options, - refreshIfExpired: isExpiredSession, - }); + if (!this.cloud && (options?.createIfNeeded || options?.forceNewSession)) { + session = await this.getNewSession(descriptor); + } else if (this.cloud) { + session = await this.getNewSession(descriptor, { + ...options, + refreshIfExpired: isExpiredSession, + }); + } if (session != null) { - await this.storeSession(sessionId, session); + await this.configuredIntegrationService.storeSession(this.authProviderId, session); } } - this.fireIfChanged(storedSession, session); + if (previousToken !== session?.accessToken) { + this.fireDidChange(); + } + return session; } - protected fireIfChanged( - storedSession: ProviderAuthenticationSession | undefined, - newSession: ProviderAuthenticationSession | undefined, - ) { - if (storedSession?.accessToken === newSession?.accessToken) return; + protected abstract getNewSession( + descriptor: IntegrationAuthenticationSessionDescriptor, + options?: + | { + createIfNeeded?: boolean; + forceNewSession?: boolean; + sync?: never; + refreshIfExpired?: boolean; + source?: Sources; + } + | { + createIfNeeded?: never; + forceNewSession?: never; + sync: boolean; + refreshIfExpired?: boolean; + source?: Sources; + }, + ): Promise; - queueMicrotask(() => this._onDidChange.fire()); - } - protected fireDidChange() { + protected fireDidChange(): void { queueMicrotask(() => this._onDidChange.fire()); } } @@ -223,87 +170,24 @@ abstract class IntegrationAuthenticationProviderBase extends IntegrationAuthenticationProviderBase { - protected override async deleteAllSecrets(sessionId: string): Promise { - await this.deleteSecret(this.getLocalSecretKey(sessionId), sessionId); - } - - protected override async storeSession(sessionId: string, session: ProviderAuthenticationSession): Promise { - await this.writeSecret(this.getLocalSecretKey(sessionId), session); - } - - protected override async restoreSession(sessionId: string): Promise { - const key = this.getLocalSecretKey(sessionId); - return convertStoredSessionToSession(await this.readSecret(key, sessionId), false); + protected override async getNewSession( + descriptor: IntegrationAuthenticationSessionDescriptor, + ): Promise { + return this.createSession(descriptor); } protected abstract createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise; - - protected override async fetchOrCreateSession( - storedSession: ProviderAuthenticationSession | undefined, - descriptor?: IntegrationAuthenticationSessionDescriptor, - options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, - ): Promise { - if (!options?.createIfNeeded && !options?.forceNewSession) return storedSession; - - return this.createSession(descriptor); - } } export abstract class CloudIntegrationAuthenticationProvider< ID extends IntegrationId = IntegrationId, > extends IntegrationAuthenticationProviderBase { - private getCloudSecretKey(id: string): `gitlens.integration.auth.cloud:${IntegrationId}|${string}` { - return `gitlens.integration.auth.cloud:${this.authProviderId}|${id}`; - } - - protected override async deleteAllSecrets(sessionId: string): Promise { - await Promise.allSettled([ - this.deleteSecret(this.getLocalSecretKey(sessionId), sessionId), - this.deleteSecret(this.getCloudSecretKey(sessionId), sessionId), - ]); - } + protected override readonly cloud: boolean = true; - protected override async storeSession(sessionId: string, session: ProviderAuthenticationSession): Promise { - await this.writeSecret(this.getCloudSecretKey(sessionId), session); - } - - /** - * This method gets the session from the storage and returns it. - * Howewer, if a cloud session is stored with a local key, it will be renamed and saved in the storage with the cloud key. - */ - protected override async restoreSession(sessionId: string): Promise { - let cloudIfMissing = false; - // At first we try to restore a token with the local key - let session = await this.readSecret(this.getLocalSecretKey(sessionId), sessionId); - if (session != null) { - // Check the `expiresAt` field - // If it has an expiresAt property and the key is the old type, then it's a cloud session, - // so delete it from the local key and - // store with the "cloud" type key, and then use that one. - // Otherwise it's a local session under the local key, so just return it. - if (session.expiresAt != null) { - cloudIfMissing = true; - await Promise.allSettled([ - this.deleteSecret(this.getLocalSecretKey(sessionId), session.id), - this.writeSecret(this.getCloudSecretKey(sessionId), session), - ]); - } - } - - // If no local session we try to restore a session with the cloud key - if (session == null) { - cloudIfMissing = true; - session = await this.readSecret(this.getCloudSecretKey(sessionId), sessionId); - } - - return convertStoredSessionToSession(session, cloudIfMissing); - } - - protected override async fetchOrCreateSession( - _storedSession: ProviderAuthenticationSession | undefined, - descriptor?: IntegrationAuthenticationSessionDescriptor, + protected override async getNewSession( + descriptor: IntegrationAuthenticationSessionDescriptor, options?: | { createIfNeeded?: boolean; @@ -321,11 +205,11 @@ export abstract class CloudIntegrationAuthenticationProvider< }, ): Promise { if (options?.forceNewSession) { - if (!(await this.disconnectSession())) { + if (!(await this.disconnectCloudSession())) { return undefined; } - void this.connectCloudIntegration(false, options?.source); + void this.connectCloudSession(false, options?.source); return undefined; } // TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session. @@ -333,18 +217,21 @@ export abstract class CloudIntegrationAuthenticationProvider< // make the call or not. let session = options?.refreshIfExpired || options?.createIfNeeded || options?.forceNewSession || options?.sync - ? await this.fetchSession(descriptor) + ? await this.getCloudSession(descriptor) : undefined; - if (shouldCreateSession(session, options)) { - const connected = await this.connectCloudIntegration(true, options?.source); + const shouldCreateSession = options?.createIfNeeded && session == null; + if (shouldCreateSession) { + const connected = await this.connectCloudSession(true, options?.source); if (!connected) return undefined; + // This should get us the session we just created with connectCloudSession, because a syncCloudIntegrations run from + // integration service should have resulted in it being created and stored by this provider session = await this.getSession(descriptor, { source: options?.source }); } return session; } - private async connectCloudIntegration(skipIfConnected: boolean, source: Sources | undefined): Promise { + private async connectCloudSession(skipIfConnected: boolean, source: Sources | undefined): Promise { if (isSupportedCloudIntegrationId(this.authProviderId)) { return this.container.integrations.connectCloudIntegrations( { integrationIds: [this.authProviderId], skipIfConnected: skipIfConnected, skipPreSync: true }, @@ -361,8 +248,8 @@ export abstract class CloudIntegrationAuthenticationProvider< return false; } - private async fetchSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + private async getCloudSession( + descriptor: IntegrationAuthenticationSessionDescriptor, ): Promise { const loggedIn = await this.container.subscription.getAuthenticationSession(false); if (!loggedIn) return undefined; @@ -387,20 +274,23 @@ export abstract class CloudIntegrationAuthenticationProvider< if (!session) return undefined; + // TODO: Once we care about domains, we should try to match the domain here against ours, and if it fails, return undefined return { - id: this.getSessionId(descriptor), + id: this.configuredIntegrationService.getSessionId(descriptor), accessToken: session.accessToken, - scopes: descriptor?.scopes ?? [], + scopes: descriptor.scopes, account: { id: '', label: '', }, cloud: true, expiresAt: new Date(session.expiresIn * 1000 + Date.now()), + // Note: do not use the session's domain, because the format is different than in our model + domain: descriptor.domain, }; } - private async disconnectSession(): Promise { + private async disconnectCloudSession(): Promise { const loggedIn = await this.container.subscription.getAuthenticationSession(false); if (!loggedIn) return false; @@ -409,58 +299,16 @@ export abstract class CloudIntegrationAuthenticationProvider< return cloudIntegrations.disconnect(this.authProviderId); } - - private async openCompletionInput(cancellationToken: CancellationToken) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - try { - if (cancellationToken.isCancellationRequested) return; - - await new Promise(resolve => { - disposables.push( - cancellationToken.onCancellationRequested(() => input.hide()), - input.onDidHide(() => resolve(undefined)), - input.onDidAccept(() => resolve(undefined)), - ); - - input.title = this.getCompletionInputTitle(); - input.placeholder = 'Please enter the provided authorization code'; - input.prompt = ''; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - } - - protected abstract getCompletionInputTitle(): string; - - private getUriHandlerDeferredExecutor(): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - const queryParams: URLSearchParams = new URLSearchParams(uri.query); - const provider = queryParams.get('provider'); - if (provider !== this.authProviderId) { - reject('Invalid provider'); - return; - } - - resolve(uri.toString(true)); - }; - } } export class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticationProvider { constructor( container: Container, authenticationService: IntegrationAuthenticationService, + configuredIntegrationService: ConfiguredIntegrationService, protected readonly authProviderId: IntegrationId, ) { - super(container, authenticationService); + super(container, authenticationService, configuredIntegrationService); this.disposables.push( authentication.onDidChangeSessions(e => { if (e.provider.id === this.authProviderId) { @@ -476,7 +324,7 @@ export class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticatio @debug() override async getSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, + descriptor: IntegrationAuthenticationSessionDescriptor, options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, ): Promise { if (descriptor == null) return undefined; @@ -490,37 +338,3 @@ export class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticatio ); } } - -function convertStoredSessionToSession( - storedSession: StoredSession, - cloudIfMissing: boolean, -): ProviderAuthenticationSession; -function convertStoredSessionToSession( - storedSession: StoredSession | undefined, - cloudIfMissing: boolean, -): ProviderAuthenticationSession | undefined; -function convertStoredSessionToSession( - storedSession: StoredSession | undefined, - cloudIfMissing: boolean, -): ProviderAuthenticationSession | undefined { - if (storedSession == null) return undefined; - - return { - id: storedSession.id, - accessToken: storedSession.accessToken, - account: { - id: storedSession.account?.id ?? '', - label: storedSession.account?.label ?? '', - }, - scopes: storedSession.scopes, - cloud: storedSession.cloud ?? cloudIfMissing, - expiresAt: storedSession.expiresAt ? new Date(storedSession.expiresAt) : undefined, - }; -} - -function shouldCreateSession( - storedSession: ProviderAuthenticationSession | undefined, - options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, -) { - return options?.createIfNeeded && storedSession == null; -} diff --git a/src/plus/integrations/authentication/integrationAuthenticationService.ts b/src/plus/integrations/authentication/integrationAuthenticationService.ts index 42844f0e09428..369f425f8dd37 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationService.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationService.ts @@ -1,88 +1,28 @@ import type { Disposable } from 'vscode'; import type { IntegrationId } from '../../../constants.integrations'; import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; -import type { StoredConfiguredIntegrationDescriptor } from '../../../constants.storage'; import type { Container } from '../../../container'; import { gate } from '../../../system/decorators/-webview/gate'; import { log } from '../../../system/decorators/log'; import { supportedIntegrationIds } from '../providers/models'; +import type { ConfiguredIntegrationService } from './configuredIntegrationService'; import type { IntegrationAuthenticationProvider } from './integrationAuthenticationProvider'; import { BuiltInAuthenticationProvider } from './integrationAuthenticationProvider'; -import type { ConfiguredIntegrationDescriptor } from './models'; import { isSupportedCloudIntegrationId } from './models'; export class IntegrationAuthenticationService implements Disposable { private readonly providers = new Map(); - private _configured?: Map; - constructor(private readonly container: Container) {} + constructor( + private readonly container: Container, + private readonly configuredIntegrationService: ConfiguredIntegrationService, + ) {} dispose(): void { this.providers.forEach(p => void p.dispose()); this.providers.clear(); } - get configured(): Map { - if (this._configured == null) { - this._configured = new Map(); - const storedConfigured = this.container.storage.get('integrations:configured'); - for (const [id, configured] of Object.entries(storedConfigured ?? {})) { - if (configured == null) continue; - const descriptors = configured.map(d => ({ - ...d, - expiresAt: d.expiresAt ? new Date(d.expiresAt) : undefined, - })); - this._configured.set(id as IntegrationId, descriptors); - } - } - - return this._configured; - } - - private async storeConfigured() { - // We need to convert the map to a record to store - const configured: Record = {}; - for (const [id, descriptors] of this.configured) { - configured[id] = descriptors.map(d => ({ - ...d, - expiresAt: d.expiresAt - ? d.expiresAt instanceof Date - ? d.expiresAt.toISOString() - : d.expiresAt - : undefined, - })); - } - - await this.container.storage.store('integrations:configured', configured); - } - - async addConfigured(descriptor: ConfiguredIntegrationDescriptor): Promise { - const descriptors = this.configured.get(descriptor.integrationId) ?? []; - // Only add if one does not exist - if (descriptors.some(d => d.domain === descriptor.domain && d.integrationId === descriptor.integrationId)) { - return; - } - descriptors.push(descriptor); - this.configured.set(descriptor.integrationId, descriptors); - await this.storeConfigured(); - } - - async removeConfigured( - descriptor: Pick, - ): Promise { - const descriptors = this.configured.get(descriptor.integrationId); - if (descriptors == null) return; - const index = descriptors.findIndex( - d => d.domain === descriptor.domain && d.integrationId === descriptor.integrationId, - ); - if (index === -1) return; - - descriptors.splice(index, 1); - this.configured.set(descriptor.integrationId, descriptors); - - await this.storeConfigured(); - } - async get(providerId: IntegrationId): Promise { return this.ensureProvider(providerId); } @@ -91,7 +31,9 @@ export class IntegrationAuthenticationService implements Disposable { async reset(): Promise { // TODO: This really isn't ideal, since it will only work for "cloud" providers as we won't have any more specific descriptors await Promise.allSettled( - supportedIntegrationIds.map(async providerId => (await this.ensureProvider(providerId)).deleteSession()), + supportedIntegrationIds.map(async providerId => + (await this.ensureProvider(providerId)).deleteAllSessions(), + ), ); } @@ -119,57 +61,85 @@ export class IntegrationAuthenticationService implements Disposable { case HostingIntegrationId.AzureDevOps: provider = new ( await import(/* webpackChunkName: "integrations" */ './azureDevOps') - ).AzureDevOpsAuthenticationProvider(this.container, this); + ).AzureDevOpsAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; case HostingIntegrationId.Bitbucket: provider = new ( await import(/* webpackChunkName: "integrations" */ './bitbucket') - ).BitbucketAuthenticationProvider(this.container, this); + ).BitbucketAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; case HostingIntegrationId.GitHub: provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitHub) ? new ( await import(/* webpackChunkName: "integrations" */ './github') - ).GitHubAuthenticationProvider(this.container, this) - : new BuiltInAuthenticationProvider(this.container, this, providerId); + ).GitHubAuthenticationProvider(this.container, this, this.configuredIntegrationService) + : new BuiltInAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + providerId, + ); break; case SelfHostedIntegrationId.CloudGitHubEnterprise: provider = new ( await import(/* webpackChunkName: "integrations" */ './github') - ).GitHubEnterpriseCloudAuthenticationProvider(this.container, this); + ).GitHubEnterpriseCloudAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + ); break; case SelfHostedIntegrationId.GitHubEnterprise: provider = new ( await import(/* webpackChunkName: "integrations" */ './github') - ).GitHubEnterpriseAuthenticationProvider(this.container, this); + ).GitHubEnterpriseAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; case HostingIntegrationId.GitLab: provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitLab) ? new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabCloudAuthenticationProvider(this.container, this) + ).GitLabCloudAuthenticationProvider(this.container, this, this.configuredIntegrationService) : new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabLocalAuthenticationProvider(this.container, this, HostingIntegrationId.GitLab); + ).GitLabLocalAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + HostingIntegrationId.GitLab, + ); break; case SelfHostedIntegrationId.CloudGitLabSelfHosted: provider = new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabSelfHostedCloudAuthenticationProvider(this.container, this); + ).GitLabSelfHostedCloudAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + ); break; case SelfHostedIntegrationId.GitLabSelfHosted: provider = new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabLocalAuthenticationProvider(this.container, this, SelfHostedIntegrationId.GitLabSelfHosted); + ).GitLabLocalAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + SelfHostedIntegrationId.GitLabSelfHosted, + ); break; case IssueIntegrationId.Jira: provider = new ( await import(/* webpackChunkName: "integrations" */ './jira') - ).JiraAuthenticationProvider(this.container, this); + ).JiraAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; default: - provider = new BuiltInAuthenticationProvider(this.container, this, providerId); + provider = new BuiltInAuthenticationProvider( + this.container, + this, + this.configuredIntegrationService, + providerId, + ); } this.providers.set(providerId, provider); } diff --git a/src/plus/integrations/authentication/jira.ts b/src/plus/integrations/authentication/jira.ts index 21a11b6ece0d1..1378d26b6da49 100644 --- a/src/plus/integrations/authentication/jira.ts +++ b/src/plus/integrations/authentication/jira.ts @@ -5,8 +5,4 @@ export class JiraAuthenticationProvider extends CloudIntegrationAuthenticationPr protected override get authProviderId(): IssueIntegrationId.Jira { return IssueIntegrationId.Jira; } - - protected override getCompletionInputTitle(): string { - return 'Connect to Jira'; - } } diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index 9d0e637d94740..0341d83033ab1 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -12,14 +12,15 @@ import { configuration } from '../../../system/-webview/configuration'; export interface ProviderAuthenticationSession extends AuthenticationSession { readonly cloud: boolean; readonly expiresAt?: Date; + readonly domain: string; } export interface ConfiguredIntegrationDescriptor { readonly cloud: boolean; readonly integrationId: IntegrationId; + readonly scopes: string; readonly domain?: string; readonly expiresAt?: string | Date; - readonly scopes: string; } export interface CloudIntegrationAuthenticationSession { diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index 9656a8b8a7068..e7cefb0bec8be 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -20,10 +20,11 @@ import { openUrl } from '../../system/-webview/vscode'; import { gate } from '../../system/decorators/-webview/gate'; import { debug, log } from '../../system/decorators/log'; import { promisifyDeferred, take } from '../../system/event'; -import { filter, filterMap, flatten, join } from '../../system/iterable'; +import { filterMap, flatten, join } from '../../system/iterable'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; import type { SubscriptionChangeEvent } from '../gk/subscriptionService'; +import type { ConfiguredIntegrationService } from './authentication/configuredIntegrationService'; import type { IntegrationAuthenticationService } from './authentication/integrationAuthenticationService'; import type { ConfiguredIntegrationDescriptor } from './authentication/models'; import { @@ -39,7 +40,6 @@ import type { IntegrationBase, IntegrationKey, IntegrationResult, - IntegrationType, IssueIntegration, ResourceDescriptor, SupportedCloudSelfHostedIntegrationIds, @@ -75,6 +75,7 @@ export class IntegrationService implements Disposable { constructor( private readonly container: Container, private readonly authenticationService: IntegrationAuthenticationService, + private readonly configuredIntegrationService: ConfiguredIntegrationService, ) { this._disposable = Disposable.from( configuration.onDidChange(e => { @@ -461,8 +462,12 @@ export class IntegrationService implements Disposable { return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); } - getConfigured(id: SupportedIntegrationIds): ConfiguredIntegrationDescriptor[] { - return this.authenticationService.configured?.get(id) ?? []; + async getConfigured( + options?: + | { id?: HostingIntegrationId | IssueIntegrationId; domain?: never; type?: 'cloud' | 'local' } + | { id?: CloudSelfHostedIntegrationId | SelfHostedIntegrationId; domain?: string; type?: never }, + ): Promise { + return this.configuredIntegrationService.getConfigured(options); } get(id: SupportedHostingIntegrationIds): Promise; @@ -493,7 +498,9 @@ export class IntegrationService implements Disposable { return integration; } - const existingConfigured = this.getConfigured(SelfHostedIntegrationId.CloudGitHubEnterprise); + const existingConfigured = await this.getConfigured({ + id: SelfHostedIntegrationId.CloudGitHubEnterprise, + }); if (existingConfigured.length) { const { domain: configuredDomain } = existingConfigured[0]; if (configuredDomain == null) throw new Error(`Domain is required for '${id}' integration`); @@ -549,7 +556,9 @@ export class IntegrationService implements Disposable { return integration; } - const existingConfigured = this.getConfigured(SelfHostedIntegrationId.CloudGitLabSelfHosted); + const existingConfigured = await this.getConfigured({ + id: SelfHostedIntegrationId.CloudGitLabSelfHosted, + }); if (existingConfigured.length) { const { domain: configuredDomain } = existingConfigured[0]; if (configuredDomain == null) throw new Error(`Domain is required for '${id}' integration`); @@ -620,16 +629,6 @@ export class IntegrationService implements Disposable { return integration; } - getLoaded(): Iterable; - getLoaded(type: 'issues'): Iterable; - getLoaded(type: 'hosting'): Iterable; - @log() - getLoaded(type?: IntegrationType): Iterable { - if (type == null) return this._integrations.values(); - - return filter(this._integrations.values(), i => i.type === type); - } - private _providersApi: Promise | undefined; private async getProvidersApi() { if (this._providersApi == null) { @@ -1024,17 +1023,6 @@ export class IntegrationService implements Disposable { ): IntegrationKey { return isSelfHostedIntegrationId(id) ? (`${id}:${domain}` as const) : id; } - - getConfiguredIntegrationDescriptors(id?: IntegrationId): ConfiguredIntegrationDescriptor[] { - const configured = this.authenticationService.configured; - if (id != null) return configured.get(id) ?? []; - const results = []; - for (const [, descriptors] of configured) { - results.push(...descriptors); - } - - return results; - } } export function remoteProviderIdToIntegrationId( diff --git a/src/plus/integrations/providers/github/sub-providers/remotes.ts b/src/plus/integrations/providers/github/sub-providers/remotes.ts index b319e33c6ee85..441949efaa54b 100644 --- a/src/plus/integrations/providers/github/sub-providers/remotes.ts +++ b/src/plus/integrations/providers/github/sub-providers/remotes.ts @@ -7,7 +7,6 @@ import { log } from '../../../../../system/decorators/log'; export class RemotesGitSubProvider extends RemotesGitProviderBase { @log({ args: { 1: false } }) - // eslint-disable-next-line @typescript-eslint/require-await async getRemotes( repoPath: string | undefined, _options?: { filter?: (remote: GitRemote) => boolean; sort?: boolean }, @@ -31,7 +30,7 @@ export class RemotesGitSubProvider extends RemotesGitProviderBase { 'https', domain, path, - getRemoteProviderMatcher(this.container, providers)(url, domain, path), + (await getRemoteProviderMatcher(this.container, providers))(url, domain, path), [ { type: 'fetch', url: url }, { type: 'push', url: url }, diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 7c219a4ad6c99..291cbc5de6d76 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -53,7 +53,7 @@ import { } from '../../../git/models/pullRequest'; import type { ProviderReference } from '../../../git/models/remoteProvider'; import type { EnrichableItem } from '../../launchpad/models/enrichedItem'; -import type { Integration } from '../integration'; +import type { Integration, IntegrationType } from '../integration'; import { getEntityIdentifierInput } from './utils'; export type ProviderAccount = Account; @@ -351,6 +351,9 @@ export interface ProviderInfo extends ProviderMetadata { export interface ProviderMetadata { domain: string; id: IntegrationId; + name: string; + type: IntegrationType; + iconKey: string; issuesPagingMode?: PagingMode; pullRequestsPagingMode?: PagingMode; scopes: string[]; @@ -365,6 +368,9 @@ export const providersMetadata: ProvidersMetadata = { [HostingIntegrationId.GitHub]: { domain: 'github.com', id: HostingIntegrationId.GitHub, + name: 'GitHub', + type: 'hosting', + iconKey: HostingIntegrationId.GitHub, issuesPagingMode: PagingMode.Repos, pullRequestsPagingMode: PagingMode.Repos, // Use 'username' property on account for PR filters @@ -381,6 +387,9 @@ export const providersMetadata: ProvidersMetadata = { [SelfHostedIntegrationId.CloudGitHubEnterprise]: { domain: '', id: SelfHostedIntegrationId.CloudGitHubEnterprise, + name: 'GitHub Enterprise', + type: 'hosting', + iconKey: SelfHostedIntegrationId.GitHubEnterprise, issuesPagingMode: PagingMode.Repos, pullRequestsPagingMode: PagingMode.Repos, // Use 'username' property on account for PR filters @@ -397,6 +406,9 @@ export const providersMetadata: ProvidersMetadata = { [SelfHostedIntegrationId.GitHubEnterprise]: { domain: '', id: SelfHostedIntegrationId.GitHubEnterprise, + name: 'GitHub Enterprise', + type: 'hosting', + iconKey: SelfHostedIntegrationId.GitHubEnterprise, issuesPagingMode: PagingMode.Repos, pullRequestsPagingMode: PagingMode.Repos, // Use 'username' property on account for PR filters @@ -413,6 +425,9 @@ export const providersMetadata: ProvidersMetadata = { [HostingIntegrationId.GitLab]: { domain: 'gitlab.com', id: HostingIntegrationId.GitLab, + name: 'GitLab', + type: 'hosting', + iconKey: HostingIntegrationId.GitLab, issuesPagingMode: PagingMode.Repo, pullRequestsPagingMode: PagingMode.Repo, // Use 'username' property on account for PR filters @@ -428,6 +443,9 @@ export const providersMetadata: ProvidersMetadata = { [SelfHostedIntegrationId.CloudGitLabSelfHosted]: { domain: '', id: SelfHostedIntegrationId.CloudGitLabSelfHosted, + name: 'Self-Hosted', + type: 'hosting', + iconKey: SelfHostedIntegrationId.GitLabSelfHosted, issuesPagingMode: PagingMode.Repo, pullRequestsPagingMode: PagingMode.Repo, // Use 'username' property on account for PR filters @@ -443,6 +461,9 @@ export const providersMetadata: ProvidersMetadata = { [SelfHostedIntegrationId.GitLabSelfHosted]: { domain: '', id: SelfHostedIntegrationId.GitLabSelfHosted, + name: 'Self-Hosted', + type: 'hosting', + iconKey: SelfHostedIntegrationId.GitLabSelfHosted, issuesPagingMode: PagingMode.Repo, pullRequestsPagingMode: PagingMode.Repo, // Use 'username' property on account for PR filters @@ -458,6 +479,9 @@ export const providersMetadata: ProvidersMetadata = { [HostingIntegrationId.Bitbucket]: { domain: 'bitbucket.org', id: HostingIntegrationId.Bitbucket, + name: 'Bitbucket', + type: 'hosting', + iconKey: HostingIntegrationId.Bitbucket, pullRequestsPagingMode: PagingMode.Repo, // Use 'id' property on account for PR filters supportedPullRequestFilters: [PullRequestFilter.Author], @@ -466,6 +490,9 @@ export const providersMetadata: ProvidersMetadata = { [HostingIntegrationId.AzureDevOps]: { domain: 'dev.azure.com', id: HostingIntegrationId.AzureDevOps, + name: 'Azure DevOps', + type: 'hosting', + iconKey: HostingIntegrationId.AzureDevOps, issuesPagingMode: PagingMode.Project, pullRequestsPagingMode: PagingMode.Repo, // Use 'id' property on account for PR filters @@ -477,6 +504,9 @@ export const providersMetadata: ProvidersMetadata = { [IssueIntegrationId.Jira]: { domain: 'atlassian.net', id: IssueIntegrationId.Jira, + name: 'Jira', + type: 'issues', + iconKey: IssueIntegrationId.Jira, scopes: [ 'read:status:jira', 'read:application-role:jira', @@ -518,6 +548,9 @@ export const providersMetadata: ProvidersMetadata = { [IssueIntegrationId.Trello]: { domain: 'trello.com', id: IssueIntegrationId.Trello, + name: 'Trello', + type: 'issues', + iconKey: IssueIntegrationId.Trello, scopes: [], }, }; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 2caaa62a1603f..02bfe60aa0b7e 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -300,10 +300,7 @@ export class ProvidersApi { provider: ProviderInfo, options?: { createSessionIfNeeded?: boolean }, ): Promise { - const providerDescriptor = - provider.domain == null || provider.scopes == null - ? undefined - : { domain: provider.domain, scopes: provider.scopes }; + const providerDescriptor = { domain: provider.domain, scopes: provider.scopes }; try { const authProvider = await this.authenticationService.get(provider.id); return ( diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 5130001227f9b..c8cca88a5128f 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -41,6 +41,7 @@ import { showPatchesView } from '../../plus/drafts/actions'; import type { Subscription } from '../../plus/gk/models/subscription'; import type { SubscriptionChangeEvent } from '../../plus/gk/subscriptionService'; import { isSubscriptionStatePaidOrTrial } from '../../plus/gk/utils/subscription.utils'; +import { providersMetadata } from '../../plus/integrations/providers/models'; import type { LaunchpadCategorizedResult } from '../../plus/launchpad/launchpadProvider'; import { getLaunchpadItemGroups } from '../../plus/launchpad/launchpadProvider'; import { getLaunchpadSummary } from '../../plus/launchpad/utils/-webview/launchpad.utils'; @@ -864,20 +865,25 @@ export class HomeWebviewProvider implements WebviewProvider - isSupportedCloudIntegrationId(i.id) + const promises = filterMap(await this.container.integrations.getConfigured(), i => + isSupportedCloudIntegrationId(i.integrationId) ? ({ - id: i.id, - name: i.name, - icon: `gl-provider-${i.icon}`, - connected: i.maybeConnected ?? (await i.isConnected()), - supports: i.type === 'hosting' ? ['prs', 'issues'] : i.type === 'issues' ? ['issues'] : [], + id: i.integrationId, + name: providersMetadata[i.integrationId].name, + icon: `gl-provider-${providersMetadata[i.integrationId].iconKey}`, + connected: true, + supports: + providersMetadata[i.integrationId].type === 'hosting' + ? ['prs', 'issues'] + : providersMetadata[i.integrationId].type === 'issues' + ? ['issues'] + : [], } satisfies IntegrationState) : undefined, ); const integrationsResults = await Promise.allSettled(promises); - const integrations = [...filterMap(integrationsResults, r => getSettledValue(r))]; + const integrations: IntegrationState[] = [...filterMap(integrationsResults, r => getSettledValue(r))]; this._defaultSupportedCloudIntegrations ??= supportedCloudIntegrationDescriptors.map(d => ({ ...d,