From 3ce820122916bde5c5fc7d32a3b09caf166ed486 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Tue, 15 Aug 2023 00:42:13 -0400 Subject: [PATCH] Splits authentication out of ServerConnection Wraps ServerConnection to handle auth specific requests Consolidates GK token access in ServerConnection through SubscriptionService --- src/container.ts | 27 ++- src/plus/gk/authenticationConnection.ts | 221 ++++++++++++++++++ src/plus/gk/authenticationProvider.ts | 295 ++++++++++++++++++++++++ src/plus/gk/serverConnection.ts | 116 ++++++++++ src/plus/subscription/authenticationProvider.ts | 289 ----------------------- src/plus/subscription/serverConnection.ts | 284 ----------------------- src/plus/subscription/subscriptionService.ts | 121 +++------- src/plus/workspaces/workspacesApi.ts | 153 ++++-------- src/plus/workspaces/workspacesService.ts | 6 +- src/uris/uriService.ts | 2 +- 10 files changed, 728 insertions(+), 786 deletions(-) create mode 100644 src/plus/gk/authenticationConnection.ts create mode 100644 src/plus/gk/authenticationProvider.ts create mode 100644 src/plus/gk/serverConnection.ts delete mode 100644 src/plus/subscription/authenticationProvider.ts delete mode 100644 src/plus/subscription/serverConnection.ts diff --git a/src/container.ts b/src/container.ts index 8abb88d..3a8932f 100644 --- a/src/container.ts +++ b/src/container.ts @@ -20,9 +20,9 @@ import { GitLabAuthenticationProvider } from './git/remotes/gitlab'; import { RichRemoteProviderService } from './git/remotes/remoteProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; +import { AccountAuthenticationProvider } from './plus/gk/authenticationProvider'; +import { ServerConnection } from './plus/gk/serverConnection'; import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; -import { SubscriptionAuthenticationProvider } from './plus/subscription/authenticationProvider'; -import { ServerConnection } from './plus/subscription/serverConnection'; import { SubscriptionService } from './plus/subscription/subscriptionService'; import { registerAccountWebviewView } from './plus/webviews/account/registration'; import { registerFocusWebviewPanel } from './plus/webviews/focus/registration'; @@ -191,13 +191,12 @@ export class Container { this._richRemoteProviders = new RichRemoteProviderService(this); - const server = new ServerConnection(this); - this._disposables.push(server); - this._disposables.push( - (this._subscriptionAuthentication = new SubscriptionAuthenticationProvider(this, server)), - ); - this._disposables.push((this._subscription = new SubscriptionService(this, previousVersion))); - this._disposables.push((this._workspaces = new WorkspacesService(this, server))); + const connection = new ServerConnection(this); + this._disposables.push(connection); + + this._disposables.push((this._accountAuthentication = new AccountAuthenticationProvider(this, connection))); + this._disposables.push((this._subscription = new SubscriptionService(this, connection, previousVersion))); + this._disposables.push((this._workspaces = new WorkspacesService(this, connection))); this._disposables.push((this._git = new GitProviderService(this))); this._disposables.push(new GitFileSystemProvider(this)); @@ -337,6 +336,11 @@ export class Container { } } + private _accountAuthentication: AccountAuthenticationProvider; + get accountAuthentication() { + return this._accountAuthentication; + } + private readonly _actionRunners: ActionRunners; get actionRunners() { return this._actionRunners; @@ -581,11 +585,6 @@ export class Container { return this._subscription; } - private _subscriptionAuthentication: SubscriptionAuthenticationProvider; - get subscriptionAuthentication() { - return this._subscriptionAuthentication; - } - private readonly _richRemoteProviders: RichRemoteProviderService; get richRemoteProviders(): RichRemoteProviderService { return this._richRemoteProviders; diff --git a/src/plus/gk/authenticationConnection.ts b/src/plus/gk/authenticationConnection.ts new file mode 100644 index 0000000..566c9ac --- /dev/null +++ b/src/plus/gk/authenticationConnection.ts @@ -0,0 +1,221 @@ +import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; +import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; +import { uuid } from '@env/crypto'; +import type { Response } from '@env/fetch'; +import type { Container } from '../../container'; +import { debug } from '../../system/decorators/log'; +import type { DeferredEvent, DeferredEventExecutor } from '../../system/event'; +import { promisifyDeferred } from '../../system/event'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import type { ServerConnection } from './serverConnection'; + +export const AuthenticationUriPathPrefix = 'did-authenticate'; + +interface AccountInfo { + id: string; + accountName: string; +} + +export class AuthenticationConnection implements Disposable { + private _cancellationSource: CancellationTokenSource | undefined; + private _deferredCodeExchanges = new Map>(); + private _pendingStates = new Map(); + private _statusBarItem: StatusBarItem | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose() {} + + abort(): Promise { + if (this._cancellationSource == null) return Promise.resolve(); + + this._cancellationSource.cancel(); + // This should allow the current auth request to abort before continuing + return new Promise(resolve => setTimeout(resolve, 50)); + } + + @debug({ args: false }) + async getAccountInfo(token: string): Promise { + const scope = getLogScope(); + + let rsp: Response; + try { + rsp = await this.connection.fetch( + Uri.joinPath(this.connection.baseApiUri, 'user').toString(), + undefined, + token, + ); + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } + + if (!rsp.ok) { + Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: { id: string; username: string } = await rsp.json(); + return { id: json.id, accountName: json.username }; + } + + @debug() + async login(scopes: string[], scopeKey: string): Promise { + this.updateStatusBarItem(true); + + // Include a state parameter here to prevent CSRF attacks + const gkstate = uuid(); + const existingStates = this._pendingStates.get(scopeKey) ?? []; + this._pendingStates.set(scopeKey, [...existingStates, gkstate]); + + const callbackUri = await env.asExternalUri( + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, + ), + ); + + const uri = Uri.joinPath(this.connection.baseAccountUri, 'register').with({ + query: `${ + scopes.includes('gitlens') ? 'referrer=gitlens&' : '' + }pass-token=true&return-url=${encodeURIComponent(callbackUri.toString())}`, + }); + void (await env.openExternal(uri)); + + // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it + let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); + if (deferredCodeExchange == null) { + deferredCodeExchange = promisifyDeferred( + this.container.uri.onDidReceiveAuthenticationUri, + this.getUriHandlerDeferredExecutor(scopeKey), + ); + this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); + } + + if (this._cancellationSource != null) { + this._cancellationSource.cancel(); + this._cancellationSource.dispose(); + this._cancellationSource = undefined; + } + + this._cancellationSource = new CancellationTokenSource(); + + void this.openCompletionInputFallback(this._cancellationSource.token); + + return Promise.race([ + deferredCodeExchange.promise, + new Promise( + (_, reject) => + // eslint-disable-next-line prefer-promise-reject-errors + this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), + ]).finally(() => { + this._cancellationSource?.cancel(); + this._cancellationSource?.dispose(); + this._cancellationSource = undefined; + + this._pendingStates.delete(scopeKey); + deferredCodeExchange?.cancel(); + this._deferredCodeExchanges.delete(scopeKey); + this.updateStatusBarItem(false); + }); + } + + private async openCompletionInputFallback(cancellationToken: CancellationToken) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + if (cancellationToken.isCancellationRequested) return; + + const uri = await new Promise(resolve => { + disposables.push( + cancellationToken.onCancellationRequested(() => input.hide()), + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(e => { + if (!e) { + input.validationMessage = undefined; + return; + } + + try { + const uri = Uri.parse(e.trim()); + if (uri.scheme && uri.scheme !== 'file') { + input.validationMessage = undefined; + return; + } + } catch {} + + input.validationMessage = 'Please enter a valid authorization URL'; + }), + input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), + ); + + input.title = 'GitKraken Sign In'; + input.placeholder = 'Please enter the provided authorization URL'; + input.prompt = 'If the auto-redirect fails, paste the authorization URL'; + + input.show(); + }); + + if (uri != null) { + this.container.uri.handleUri(uri); + } + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + } + + private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + // TODO: We should really support a code to token exchange, but just return the token from the query string + // await this.exchangeCodeForToken(uri.query); + // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + + const acceptedStates = this._pendingStates.get(_scopeKey); + const state = queryParams.get('gkstate'); + + if (acceptedStates == null || !state || !acceptedStates.includes(state)) { + // A common scenario of this happening is if you: + // 1. Trigger a sign in with one set of scopes + // 2. Before finishing 1, you trigger a sign in with a different set of scopes + // In this scenario we should just return and wait for the next UriHandler event + // to run as we are probably still waiting on the user to hit 'Continue' + Logger.log('State not found in accepted state. Skipping this execution...'); + return; + } + + const accessToken = queryParams.get('access-token'); + const code = queryParams.get('code'); + const token = accessToken ?? code; + + if (token == null) { + reject('Token not returned'); + } else { + resolve(token); + } + }; + } + + private updateStatusBarItem(signingIn?: boolean) { + if (signingIn && this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); + this._statusBarItem.name = 'GitKraken Sign in'; + this._statusBarItem.text = 'Signing in to GitKraken...'; + this._statusBarItem.show(); + } + + if (!signingIn && this._statusBarItem != null) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } +} diff --git a/src/plus/gk/authenticationProvider.ts b/src/plus/gk/authenticationProvider.ts new file mode 100644 index 0000000..9400415 --- /dev/null +++ b/src/plus/gk/authenticationProvider.ts @@ -0,0 +1,295 @@ +import type { + AuthenticationProvider, + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, +} from 'vscode'; +import { authentication, Disposable, EventEmitter, window } from 'vscode'; +import { uuid } from '@env/crypto'; +import type { Container, Environment } from '../../container'; +import { debug } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; +import { AuthenticationConnection } from './authenticationConnection'; +import type { ServerConnection } from './serverConnection'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; +} + +export const authenticationProviderId = 'gitlens+'; +export const authenticationProviderScopes = ['gitlens']; +const authenticationLabel = 'GitKraken: GitLens'; + +export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable { + private _onDidChangeSessions = new EventEmitter(); + get onDidChangeSessions() { + return this._onDidChangeSessions.event; + } + + private readonly _disposable: Disposable; + private readonly _authConnection: AuthenticationConnection; + private _sessionsPromise: Promise; + + constructor( + private readonly container: Container, + connection: ServerConnection, + ) { + this._authConnection = new AuthenticationConnection(container, connection); + + // Contains the current state of the sessions we have available. + this._sessionsPromise = this.getSessionsFromStorage(); + + this._disposable = Disposable.from( + this._authConnection, + authentication.registerAuthenticationProvider(authenticationProviderId, authenticationLabel, this, { + supportsMultipleAccounts: false, + }), + this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), + ); + } + + dispose() { + this._disposable.dispose(); + } + + private get secretStorageKey(): `gitlens.plus.auth:${Environment}` { + return `gitlens.plus.auth:${this.container.env}`; + } + + abort(): Promise { + return this._authConnection.abort(); + } + + @debug() + public async createSession(scopes: string[]): Promise { + const scope = getLogScope(); + + // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) + scopes = scopes.sort(); + const scopesKey = getScopesKey(scopes); + + try { + const token = await this._authConnection.login(scopes, scopesKey); + const session = await this.createSessionForToken(token, scopes); + + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); + if (sessionIndex > -1) { + sessions.splice(sessionIndex, 1, session); + } else { + sessions.push(session); + } + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + + return session; + } catch (ex) { + // If login was cancelled, do not notify user. + if (ex === 'Cancelled') throw ex; + + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign in to GitKraken: ${ex}`); + throw ex; + } + } + + @debug() + async getSessions(scopes?: string[]): Promise { + const scope = getLogScope(); + + scopes = scopes?.sort(); + const scopesKey = getScopesKey(scopes); + + const sessions = await this._sessionsPromise; + const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; + + setLogScopeExit(scope, ` \u2022 Found ${filtered.length} sessions`); + + return filtered; + } + + @debug() + public async removeSession(id: string) { + const scope = getLogScope(); + + try { + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(session => session.id === id); + if (sessionIndex === -1) { + Logger.log(`Unable to remove session ${id}; Not found`); + return; + } + + const session = sessions[sessionIndex]; + sessions.splice(sessionIndex, 1); + + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + } catch (ex) { + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); + throw ex; + } + } + + @debug() + public async removeSessionsByScopes(scopes?: string[]) { + const scope = getLogScope(); + + try { + scopes = scopes?.sort(); + const scopesKey = getScopesKey(scopes); + + const removed: AuthenticationSession[] = []; + + let index = 0; + + const sessions = await this._sessionsPromise; + + for (const session of sessions) { + if (getScopesKey(session.scopes) !== scopesKey) { + index++; + continue; + } + + sessions.splice(index, 1); + removed.push(session); + } + + if (removed.length === 0) return; + + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [], removed: removed, changed: [] }); + } catch (ex) { + Logger.error(ex, scope); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); + throw ex; + } + } + + private async checkForUpdates() { + const previousSessions = await this._sessionsPromise; + this._sessionsPromise = this.getSessionsFromStorage(); + const storedSessions = await this._sessionsPromise; + + const added: AuthenticationSession[] = []; + const removed: AuthenticationSession[] = []; + + for (const session of storedSessions) { + if (previousSessions.some(s => s.id === session.id)) continue; + + // Another window added a session, so let our window know about it + added.push(session); + } + + for (const session of previousSessions) { + if (storedSessions.some(s => s.id === session.id)) continue; + + // Another window has removed this session (or logged out), so let our window know about it + removed.push(session); + } + + if (added.length || removed.length) { + Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); + this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); + } + } + + private async createSessionForToken(token: string, scopes: string[]): Promise { + const userInfo = await this._authConnection.getAccountInfo(token); + return { + id: uuid(), + accessToken: token, + account: { label: userInfo.accountName, id: userInfo.id }, + scopes: scopes, + }; + } + + private async getSessionsFromStorage(): Promise { + let storedSessions: StoredSession[]; + + try { + const sessionsJSON = await this.container.storage.getSecret(this.secretStorageKey); + if (!sessionsJSON || sessionsJSON === '[]') return []; + + try { + storedSessions = JSON.parse(sessionsJSON); + } catch (ex) { + try { + await this.container.storage.deleteSecret(this.secretStorageKey); + } catch {} + + throw ex; + } + } catch (ex) { + Logger.error(ex, 'Unable to read sessions from storage'); + return []; + } + + const sessionPromises = storedSessions.map(async (session: StoredSession) => { + const scopesKey = getScopesKey(session.scopes); + + Logger.debug(`Read session from storage with scopes=${scopesKey}`); + + let userInfo: { id: string; accountName: string } | undefined; + if (session.account == null) { + try { + userInfo = await this._authConnection.getAccountInfo(session.accessToken); + Logger.debug(`Verified session with scopes=${scopesKey}`); + } catch (ex) { + // Remove sessions that return unauthorized response + if (ex.message === 'Unauthorized') return undefined; + } + } + + return { + id: session.id, + account: { + label: + session.account != null + ? session.account.label ?? session.account.displayName ?? '' + : userInfo?.accountName ?? '', + id: session.account?.id ?? userInfo?.id ?? '', + }, + scopes: session.scopes, + accessToken: session.accessToken, + }; + }); + + const verifiedSessions = (await Promise.allSettled(sessionPromises)) + .filter(p => p.status === 'fulfilled') + .map(p => (p as PromiseFulfilledResult).value) + .filter((p?: T): p is T => Boolean(p)); + + Logger.debug(`Found ${verifiedSessions.length} verified sessions`); + if (verifiedSessions.length !== storedSessions.length) { + await this.storeSessions(verifiedSessions); + } + return verifiedSessions; + } + + private async storeSessions(sessions: AuthenticationSession[]): Promise { + try { + this._sessionsPromise = Promise.resolve(sessions); + await this.container.storage.storeSecret(this.secretStorageKey, JSON.stringify(sessions)); + } catch (ex) { + Logger.error(ex, `Unable to store ${sessions.length} sessions`); + } + } +} + +function getScopesKey(scopes: readonly string[]): string; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined { + return scopes?.join('|'); +} diff --git a/src/plus/gk/serverConnection.ts b/src/plus/gk/serverConnection.ts new file mode 100644 index 0000000..bcb5835 --- /dev/null +++ b/src/plus/gk/serverConnection.ts @@ -0,0 +1,116 @@ +import type { Disposable } from 'vscode'; +import { Uri } from 'vscode'; +import type { RequestInfo, RequestInit, Response } from '@env/fetch'; +import { fetch as _fetch, getProxyAgent } from '@env/fetch'; +import type { Container } from '../../container'; +import { memoize } from '../../system/decorators/memoize'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; + +export class ServerConnection implements Disposable { + constructor(private readonly container: Container) {} + + dispose() {} + + @memoize() + get baseApiUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://stagingapi.gitkraken.com'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://devapi.gitkraken.com'); + } + + return Uri.parse('https://api.gitkraken.com'); + } + + @memoize() + get baseAccountUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://stagingapp.gitkraken.com'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://devapp.gitkraken.com'); + } + + return Uri.parse('https://app.gitkraken.com'); + } + + @memoize() + get baseGkApiUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://staging-api.gitkraken.dev'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://dev-api.gitkraken.dev'); + } + + return Uri.parse('https://api.gitkraken.dev'); + } + + @memoize() + get baseSiteUri(): Uri { + const { env } = this.container; + if (env === 'staging') { + return Uri.parse('https://staging.gitkraken.com'); + } + + if (env === 'dev') { + return Uri.parse('https://dev.gitkraken.com'); + } + + return Uri.parse('https://gitkraken.com'); + } + + @memoize() + get userAgent(): string { + // TODO@eamodio figure out standardized format/structure for our user agents + return 'Visual-Studio-Code-GitLens'; + } + + async fetch(url: RequestInfo, init?: RequestInit, token?: string): Promise { + const scope = getLogScope(); + + try { + token ??= await this.getAccessToken(); + const options = { + agent: getProxyAgent(), + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': this.userAgent, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }; + return await _fetch(url, options); + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } + } + + async fetchGraphQL(url: RequestInfo, request: GraphQLRequest, init?: RequestInit) { + return this.fetch(url, { + method: 'POST', + ...init, + body: JSON.stringify(request), + }); + } + + private async getAccessToken() { + const session = await this.container.subscription.getAuthenticationSession(); + if (session != null) return session.accessToken; + + throw new Error('Authentication required'); + } +} + +export interface GraphQLRequest { + query: string; + operationName?: string; + variables?: Record; +} diff --git a/src/plus/subscription/authenticationProvider.ts b/src/plus/subscription/authenticationProvider.ts deleted file mode 100644 index 004a009..0000000 --- a/src/plus/subscription/authenticationProvider.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { - AuthenticationProvider, - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, -} from 'vscode'; -import { authentication, Disposable, EventEmitter, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { Container, Environment } from '../../container'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; -import type { ServerConnection } from './serverConnection'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -export const authenticationProviderId = 'gitlens+'; -const authenticationLabel = 'GitKraken: GitLens'; - -export class SubscriptionAuthenticationProvider implements AuthenticationProvider, Disposable { - private _onDidChangeSessions = new EventEmitter(); - get onDidChangeSessions() { - return this._onDidChangeSessions.event; - } - - private readonly _disposable: Disposable; - private _sessionsPromise: Promise; - - constructor( - private readonly container: Container, - private readonly server: ServerConnection, - ) { - // Contains the current state of the sessions we have available. - this._sessionsPromise = this.getSessionsFromStorage(); - - this._disposable = Disposable.from( - authentication.registerAuthenticationProvider(authenticationProviderId, authenticationLabel, this, { - supportsMultipleAccounts: false, - }), - this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), - ); - } - - dispose() { - this._disposable.dispose(); - } - - private get secretStorageKey(): `gitlens.plus.auth:${Environment}` { - return `gitlens.plus.auth:${this.container.env}`; - } - - abort(): Promise { - return this.server.abort(); - } - - @debug() - public async createSession(scopes: string[]): Promise { - const scope = getLogScope(); - - // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) - scopes = scopes.sort(); - const scopesKey = getScopesKey(scopes); - - try { - const token = await this.server.login(scopes, scopesKey); - const session = await this.createSessionForToken(token, scopes); - - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); - if (sessionIndex > -1) { - sessions.splice(sessionIndex, 1, session); - } else { - sessions.push(session); - } - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - - return session; - } catch (ex) { - // If login was cancelled, do not notify user. - if (ex === 'Cancelled') throw ex; - - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign in to GitKraken: ${ex}`); - throw ex; - } - } - - @debug() - async getSessions(scopes?: string[]): Promise { - const scope = getLogScope(); - - scopes = scopes?.sort(); - const scopesKey = getScopesKey(scopes); - - const sessions = await this._sessionsPromise; - const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; - - setLogScopeExit(scope, ` \u2022 Found ${filtered.length} sessions`); - - return filtered; - } - - @debug() - public async removeSession(id: string) { - const scope = getLogScope(); - - try { - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(session => session.id === id); - if (sessionIndex === -1) { - Logger.log(`Unable to remove session ${id}; Not found`); - return; - } - - const session = sessions[sessionIndex]; - sessions.splice(sessionIndex, 1); - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); - } catch (ex) { - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); - throw ex; - } - } - - @debug() - public async removeSessionsByScopes(scopes?: string[]) { - const scope = getLogScope(); - - try { - scopes = scopes?.sort(); - const scopesKey = getScopesKey(scopes); - - const removed: AuthenticationSession[] = []; - - let index = 0; - - const sessions = await this._sessionsPromise; - - for (const session of sessions) { - if (getScopesKey(session.scopes) !== scopesKey) { - index++; - continue; - } - - sessions.splice(index, 1); - removed.push(session); - } - - if (removed.length === 0) return; - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [], removed: removed, changed: [] }); - } catch (ex) { - Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); - throw ex; - } - } - - private async checkForUpdates() { - const previousSessions = await this._sessionsPromise; - this._sessionsPromise = this.getSessionsFromStorage(); - const storedSessions = await this._sessionsPromise; - - const added: AuthenticationSession[] = []; - const removed: AuthenticationSession[] = []; - - for (const session of storedSessions) { - if (previousSessions.some(s => s.id === session.id)) continue; - - // Another window added a session, so let our window know about it - added.push(session); - } - - for (const session of previousSessions) { - if (storedSessions.some(s => s.id === session.id)) continue; - - // Another window has removed this session (or logged out), so let our window know about it - removed.push(session); - } - - if (added.length || removed.length) { - Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); - this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); - } - } - - private async createSessionForToken(token: string, scopes: string[]): Promise { - const userInfo = await this.server.getAccountInfo(token); - return { - id: uuid(), - accessToken: token, - account: { label: userInfo.accountName, id: userInfo.id }, - scopes: scopes, - }; - } - - private async getSessionsFromStorage(): Promise { - let storedSessions: StoredSession[]; - - try { - const sessionsJSON = await this.container.storage.getSecret(this.secretStorageKey); - if (!sessionsJSON || sessionsJSON === '[]') return []; - - try { - storedSessions = JSON.parse(sessionsJSON); - } catch (ex) { - try { - await this.container.storage.deleteSecret(this.secretStorageKey); - } catch {} - - throw ex; - } - } catch (ex) { - Logger.error(ex, 'Unable to read sessions from storage'); - return []; - } - - const sessionPromises = storedSessions.map(async (session: StoredSession) => { - const scopesKey = getScopesKey(session.scopes); - - Logger.debug(`Read session from storage with scopes=${scopesKey}`); - - let userInfo: { id: string; accountName: string } | undefined; - if (session.account == null) { - try { - userInfo = await this.server.getAccountInfo(session.accessToken); - Logger.debug(`Verified session with scopes=${scopesKey}`); - } catch (ex) { - // Remove sessions that return unauthorized response - if (ex.message === 'Unauthorized') return undefined; - } - } - - return { - id: session.id, - account: { - label: - session.account != null - ? session.account.label ?? session.account.displayName ?? '' - : userInfo?.accountName ?? '', - id: session.account?.id ?? userInfo?.id ?? '', - }, - scopes: session.scopes, - accessToken: session.accessToken, - }; - }); - - const verifiedSessions = (await Promise.allSettled(sessionPromises)) - .filter(p => p.status === 'fulfilled') - .map(p => (p as PromiseFulfilledResult).value) - .filter((p?: T): p is T => Boolean(p)); - - Logger.debug(`Found ${verifiedSessions.length} verified sessions`); - if (verifiedSessions.length !== storedSessions.length) { - await this.storeSessions(verifiedSessions); - } - return verifiedSessions; - } - - private async storeSessions(sessions: AuthenticationSession[]): Promise { - try { - this._sessionsPromise = Promise.resolve(sessions); - await this.container.storage.storeSecret(this.secretStorageKey, JSON.stringify(sessions)); - } catch (ex) { - Logger.error(ex, `Unable to store ${sessions.length} sessions`); - } - } -} - -function getScopesKey(scopes: readonly string[]): string; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined { - return scopes?.join('|'); -} diff --git a/src/plus/subscription/serverConnection.ts b/src/plus/subscription/serverConnection.ts deleted file mode 100644 index 41e67ce..0000000 --- a/src/plus/subscription/serverConnection.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; -import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { RequestInfo, RequestInit, Response } from '@env/fetch'; -import { fetch, getProxyAgent } from '@env/fetch'; -import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; -import type { DeferredEvent, DeferredEventExecutor } from '../../system/event'; -import { promisifyDeferred } from '../../system/event'; -import { Logger } from '../../system/logger'; -import { getLogScope } from '../../system/logger.scope'; - -export const AuthenticationUriPathPrefix = 'did-authenticate'; -// TODO: What user-agent should we use? -const userAgent = 'Visual-Studio-Code-GitLens'; - -interface AccountInfo { - id: string; - accountName: string; -} - -interface GraphQLRequest { - query: string; - operationName?: string; - variables?: Record; -} - -export class ServerConnection implements Disposable { - private _cancellationSource: CancellationTokenSource | undefined; - private _deferredCodeExchanges = new Map>(); - private _pendingStates = new Map(); - private _statusBarItem: StatusBarItem | undefined; - - constructor(private readonly container: Container) {} - - dispose() {} - - @memoize() - private get baseApiUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapi.gitkraken.com'); - } - - return Uri.parse('https://api.gitkraken.com'); - } - - @memoize() - private get baseAccountUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapp.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapp.gitkraken.com'); - } - - return Uri.parse('https://app.gitkraken.com'); - } - - abort(): Promise { - if (this._cancellationSource == null) return Promise.resolve(); - - this._cancellationSource.cancel(); - // This should allow the current auth request to abort before continuing - return new Promise(resolve => setTimeout(resolve, 50)); - } - - @debug({ args: false }) - async getAccountInfo(token: string): Promise { - const scope = getLogScope(); - - let rsp: Response; - try { - rsp = await fetch(Uri.joinPath(this.baseApiUri, 'user').toString(), { - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': userAgent, - }, - }); - } catch (ex) { - Logger.error(ex, scope); - throw ex; - } - - if (!rsp.ok) { - Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); - throw new Error(rsp.statusText); - } - - const json: { id: string; username: string } = await rsp.json(); - return { id: json.id, accountName: json.username }; - } - - @debug() - async login(scopes: string[], scopeKey: string): Promise { - this.updateStatusBarItem(true); - - // Include a state parameter here to prevent CSRF attacks - const gkstate = uuid(); - const existingStates = this._pendingStates.get(scopeKey) ?? []; - this._pendingStates.set(scopeKey, [...existingStates, gkstate]); - - const callbackUri = await env.asExternalUri( - Uri.parse( - `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, - ), - ); - - const uri = Uri.joinPath(this.baseAccountUri, 'register').with({ - query: `${ - scopes.includes('gitlens') ? 'referrer=gitlens&' : '' - }pass-token=true&return-url=${encodeURIComponent(callbackUri.toString())}`, - }); - void (await env.openExternal(uri)); - - // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it - let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); - if (deferredCodeExchange == null) { - deferredCodeExchange = promisifyDeferred( - this.container.uri.onDidReceiveAuthenticationUri, - this.getUriHandlerDeferredExecutor(scopeKey), - ); - this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - this._cancellationSource.dispose(); - this._cancellationSource = undefined; - } - - this._cancellationSource = new CancellationTokenSource(); - - void this.openCompletionInputFallback(this._cancellationSource.token); - - return Promise.race([ - deferredCodeExchange.promise, - new Promise( - (_, reject) => - // eslint-disable-next-line prefer-promise-reject-errors - this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), - ), - new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), - ]).finally(() => { - this._cancellationSource?.cancel(); - this._cancellationSource?.dispose(); - this._cancellationSource = undefined; - - this._pendingStates.delete(scopeKey); - deferredCodeExchange?.cancel(); - this._deferredCodeExchanges.delete(scopeKey); - this.updateStatusBarItem(false); - }); - } - - private async openCompletionInputFallback(cancellationToken: CancellationToken) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - try { - if (cancellationToken.isCancellationRequested) return; - - const uri = await new Promise(resolve => { - disposables.push( - cancellationToken.onCancellationRequested(() => input.hide()), - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(e => { - if (!e) { - input.validationMessage = undefined; - return; - } - - try { - const uri = Uri.parse(e.trim()); - if (uri.scheme && uri.scheme !== 'file') { - input.validationMessage = undefined; - return; - } - } catch {} - - input.validationMessage = 'Please enter a valid authorization URL'; - }), - input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), - ); - - input.title = 'GitKraken Sign In'; - input.placeholder = 'Please enter the provided authorization URL'; - input.prompt = 'If the auto-redirect fails, paste the authorization URL'; - - input.show(); - }); - - if (uri != null) { - this.container.uri.handleUri(uri); - } - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - } - - private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - // TODO: We should really support a code to token exchange, but just return the token from the query string - // await this.exchangeCodeForToken(uri.query); - // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned - const queryParams: URLSearchParams = new URLSearchParams(uri.query); - - const acceptedStates = this._pendingStates.get(_scopeKey); - const state = queryParams.get('gkstate'); - - if (acceptedStates == null || !state || !acceptedStates.includes(state)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - Logger.log('State not found in accepted state. Skipping this execution...'); - return; - } - - const accessToken = queryParams.get('access-token'); - const code = queryParams.get('code'); - const token = accessToken ?? code; - - if (token == null) { - reject('Token not returned'); - } else { - resolve(token); - } - }; - } - - private updateStatusBarItem(signingIn?: boolean) { - if (signingIn && this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); - this._statusBarItem.name = 'GitKraken Sign in'; - this._statusBarItem.text = 'Signing in to GitKraken...'; - this._statusBarItem.show(); - } - - if (!signingIn && this._statusBarItem != null) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - } - - async fetchGraphql(data: GraphQLRequest, token: string, init?: RequestInit) { - return this.fetchCore(Uri.joinPath(this.baseAccountUri, 'api/projects/graphql').toString(), token, { - method: 'POST', - body: JSON.stringify(data), - ...init, - }); - } - - private async fetchCore(url: RequestInfo, token: string, init?: RequestInit): Promise { - const scope = getLogScope(); - - try { - const options = { - agent: getProxyAgent(), - ...init, - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - ...init?.headers, - }, - }; - return await fetch(url, options); - } catch (ex) { - Logger.error(ex, scope); - throw ex; - } - } -} diff --git a/src/plus/subscription/subscriptionService.ts b/src/plus/subscription/subscriptionService.ts index 5ca2d9a..ada8ee1 100644 --- a/src/plus/subscription/subscriptionService.ts +++ b/src/plus/subscription/subscriptionService.ts @@ -20,7 +20,6 @@ import { Uri, window, } from 'vscode'; -import { fetch, getProxyAgent } from '@env/fetch'; import { getPlatform } from '@env/platform'; import type { CoreColors } from '../../constants'; import { Commands } from '../../constants'; @@ -48,7 +47,6 @@ import { setContext } from '../../system/context'; import { createFromDateDelta } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; import type { Deferrable } from '../../system/function'; import { debounce, once } from '../../system/function'; import { Logger } from '../../system/logger'; @@ -57,12 +55,10 @@ import { flatten } from '../../system/object'; import { pluralize } from '../../system/string'; import { openWalkthrough } from '../../system/utils'; import { satisfies } from '../../system/version'; -import { authenticationProviderId } from './authenticationProvider'; +import { authenticationProviderId, authenticationProviderScopes } from '../gk/authenticationProvider'; +import type { ServerConnection } from '../gk/serverConnection'; import { ensurePlusFeaturesEnabled } from './utils'; -// TODO: What user-agent should we use? -const userAgent = 'Visual-Studio-Code-GitLens'; - export interface SubscriptionChangeEvent { readonly current: Subscription; readonly previous: Subscription; @@ -70,8 +66,6 @@ export interface SubscriptionChangeEvent { } export class SubscriptionService implements Disposable { - private static authenticationScopes = ['gitlens']; - private _onDidChange = new EventEmitter(); get onDidChange(): Event { return this._onDidChange.event; @@ -84,11 +78,12 @@ export class SubscriptionService implements Disposable { constructor( private readonly container: Container, + private readonly connection: ServerConnection, previousVersion: string | undefined, ) { this._disposable = Disposable.from( once(container.onReady)(this.onReady, this), - this.container.subscriptionAuthentication.onDidChangeSessions( + this.container.accountAuthentication.onDidChangeSessions( e => setTimeout(() => this.onAuthenticationChanged(e), 0), this, ), @@ -139,48 +134,6 @@ export class SubscriptionService implements Disposable { void this.validate(); } - @memoize() - private get baseApiUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://devapi.gitkraken.com'); - } - - return Uri.parse('https://api.gitkraken.com'); - } - - @memoize() - private get baseAccountUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://stagingapp.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://devapp.gitkraken.com'); - } - - return Uri.parse('https://app.gitkraken.com'); - } - - @memoize() - private get baseSiteUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://staging.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://dev.gitkraken.com'); - } - - return Uri.parse('https://gitkraken.com'); - } - private _etag: number = 0; get etag(): number { return this._etag; @@ -222,6 +175,10 @@ export class SubscriptionService implements Disposable { ]; } + async getAuthenticationSession(createIfNeeded: boolean = false): Promise { + return this.ensureSession(createIfNeeded); + } + async getSubscription(cached = false): Promise { const promise = this.ensureSession(false); if (!cached) { @@ -255,7 +212,7 @@ export class SubscriptionService implements Disposable { if (!(await ensurePlusFeaturesEnabled())) return false; // Abort any waiting authentication to ensure we can start a new flow - await this.container.subscriptionAuthentication.abort(); + await this.container.accountAuthentication.abort(); void this.showAccountView(); const session = await this.ensureSession(true); @@ -324,17 +281,15 @@ export class SubscriptionService implements Disposable { this._validationTimer = undefined; } - await this.container.subscriptionAuthentication.abort(); + await this.container.accountAuthentication.abort(); this._sessionPromise = undefined; if (this._session != null) { - void this.container.subscriptionAuthentication.removeSession(this._session.id); + void this.container.accountAuthentication.removeSession(this._session.id); this._session = undefined; } else { // Even if we don't have a session, make sure to remove any other matching sessions - void this.container.subscriptionAuthentication.removeSessionsByScopes( - SubscriptionService.authenticationScopes, - ); + void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); } if (reset && this.container.debugging) { @@ -369,7 +324,7 @@ export class SubscriptionService implements Disposable { @log() manage(): void { - void env.openExternal(this.baseAccountUri); + void env.openExternal(this.connection.baseAccountUri); } @log() @@ -380,7 +335,9 @@ export class SubscriptionService implements Disposable { this.showPlans(); } else { void env.openExternal( - Uri.joinPath(this.baseAccountUri, 'subscription').with({ query: 'product=gitlens&license=PRO' }), + Uri.joinPath(this.connection.baseAccountUri, 'subscription').with({ + query: 'product=gitlens&license=PRO', + }), ); } await this.showAccountView(); @@ -399,16 +356,14 @@ export class SubscriptionService implements Disposable { if (session == null) return false; try { - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { - method: 'POST', - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', + const rsp = await this.connection.fetch( + Uri.joinPath(this.connection.baseApiUri, 'resend-email').toString(), + { + method: 'POST', + body: JSON.stringify({ id: session.account.id }), }, - body: JSON.stringify({ id: session.account.id }), - }); + session.accessToken, + ); if (!rsp.ok) { debugger; @@ -455,7 +410,7 @@ export class SubscriptionService implements Disposable { } private showPlans(): void { - void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); + void env.openExternal(Uri.joinPath(this.connection.baseSiteUri, 'gitlens/pricing')); } @gate() @@ -595,16 +550,14 @@ export class SubscriptionService implements Disposable { previewExpiresOn: this._subscription.previewTrial?.expiresOn, }; - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { - method: 'POST', - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', + const rsp = await this.connection.fetch( + Uri.joinPath(this.connection.baseApiUri, 'gitlens/checkin').toString(), + { + method: 'POST', + body: JSON.stringify(checkInData), }, - body: JSON.stringify(checkInData), - }); + session.accessToken, + ); if (!rsp.ok) { throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); @@ -766,14 +719,10 @@ export class SubscriptionService implements Disposable { let session: AuthenticationSession | null | undefined; try { - session = await authentication.getSession( - authenticationProviderId, - SubscriptionService.authenticationScopes, - { - createIfNone: createIfNeeded, - silent: !createIfNeeded, - }, - ); + session = await authentication.getSession(authenticationProviderId, authenticationProviderScopes, { + createIfNone: createIfNeeded, + silent: !createIfNeeded, + }); } catch (ex) { session = null; diff --git a/src/plus/workspaces/workspacesApi.ts b/src/plus/workspaces/workspacesApi.ts index 1190844..b4acc07 100644 --- a/src/plus/workspaces/workspacesApi.ts +++ b/src/plus/workspaces/workspacesApi.ts @@ -1,6 +1,8 @@ +import { Uri } from 'vscode'; +import type { RequestInit } from '@env/fetch'; import type { Container } from '../../container'; import { Logger } from '../../system/logger'; -import type { ServerConnection } from '../subscription/serverConnection'; +import type { GraphQLRequest, ServerConnection } from '../gk/serverConnection'; import type { AddRepositoriesToWorkspaceResponse, AddWorkspaceRepoDescriptor, @@ -19,20 +21,9 @@ import { CloudWorkspaceProviderInputType, defaultWorkspaceCount, defaultWorkspac export class WorkspacesApi { constructor( private readonly container: Container, - private readonly server: ServerConnection, + private readonly connection: ServerConnection, ) {} - private async getAccessToken() { - // TODO: should probably get scopes from somewhere - const sessions = await this.container.subscriptionAuthentication.getSessions(['gitlens']); - if (!sessions.length) { - return; - } - - const session = sessions[0]; - return session.accessToken; - } - async getWorkspace( id: string, options?: { @@ -41,11 +32,6 @@ export class WorkspacesApi { repoPage?: number; }, ): Promise { - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } - let repoQuery: string | undefined; if (options?.includeRepositories) { let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`; @@ -96,12 +82,7 @@ export class WorkspacesApi { } `; - const rsp = await this.server.fetchGraphql( - { - query: query, - }, - accessToken, - ); + const rsp = await this.fetch({ query: query }); if (!rsp.ok) { Logger.error(undefined, `Getting workspace failed: (${rsp.status}) ${rsp.statusText}`); @@ -122,11 +103,6 @@ export class WorkspacesApi { repoCount?: number; repoPage?: number; }): Promise { - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } - let repoQuery: string | undefined; if (options?.includeRepositories) { let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`; @@ -206,12 +182,7 @@ export class WorkspacesApi { query += '}'; - const rsp = await this.server.fetchGraphql( - { - query: query, - }, - accessToken, - ); + const rsp = await this.fetch({ query: query }); if (!rsp.ok) { Logger.error(undefined, `Getting workspaces failed: (${rsp.status}) ${rsp.statusText}`); @@ -254,11 +225,6 @@ export class WorkspacesApi { page?: number; }, ): Promise { - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } - let queryparams = `(first: ${options?.count ?? defaultWorkspaceRepoCount}`; if (options?.cursor) { queryparams += `, after: "${options.cursor}"`; @@ -267,12 +233,11 @@ export class WorkspacesApi { } queryparams += ')'; - const rsp = await this.server.fetchGraphql( - { - query: ` - query getWorkspaceRepos { - project (id: "${workspaceId}") { - provider_data { + const rsp = await this.fetch({ + query: ` + query getWorkspaceRepos { + project (id: "${workspaceId}") { + provider_data { repositories ${queryparams} { total_count page_info { @@ -291,12 +256,10 @@ export class WorkspacesApi { } } } - } - } + } + } `, - }, - accessToken, - ); + }); if (!rsp.ok) { Logger.error(undefined, `Getting workspace repos failed: (${rsp.status}) ${rsp.statusText}`); @@ -337,15 +300,9 @@ export class WorkspacesApi { return; } - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } - - const rsp = await this.server.fetchGraphql( - { - query: ` - mutation createWorkspace { + const rsp = await this.fetch({ + query: ` + mutation createWorkspace { create_project( input: { type: GK_PROJECT @@ -369,11 +326,9 @@ export class WorkspacesApi { azure_project repo_relation } - } + } `, - }, - accessToken, - ); + }); if (!rsp.ok) { Logger.error(undefined, `Creating workspace failed: (${rsp.status}) ${rsp.statusText}`); @@ -386,25 +341,17 @@ export class WorkspacesApi { } async deleteWorkspace(workspaceId: string): Promise { - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } - - const rsp = await this.server.fetchGraphql( - { - query: ` - mutation deleteWorkspace { + const rsp = await this.fetch({ + query: ` + mutation deleteWorkspace { delete_project( id: "${workspaceId}" ) { id } - } + } `, - }, - accessToken, - ); + }); if (!rsp.ok) { Logger.error(undefined, `Deleting workspace failed: (${rsp.status}) ${rsp.statusText}`); @@ -427,14 +374,7 @@ export class WorkspacesApi { workspaceId: string, repos: AddWorkspaceRepoDescriptor[], ): Promise { - if (repos.length === 0) { - return; - } - - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } + if (repos.length === 0) return; let reposQuery = '['; reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); @@ -456,10 +396,9 @@ export class WorkspacesApi { ) .join(','); - const rsp = await this.server.fetchGraphql( - { - query: ` - mutation addReposToWorkspace { + const rsp = await this.fetch({ + query: ` + mutation addReposToWorkspace { add_repositories_to_project( input: { project_id: "${workspaceId}", @@ -471,11 +410,9 @@ export class WorkspacesApi { ${reposReturnQuery} } } - } + } `, - }, - accessToken, - ); + }); if (!rsp.ok) { Logger.error(undefined, `Adding repositories to workspace failed: (${rsp.status}) ${rsp.statusText}`); @@ -500,23 +437,15 @@ export class WorkspacesApi { workspaceId: string, repos: RemoveWorkspaceRepoDescriptor[], ): Promise { - if (repos.length === 0) { - return; - } - - const accessToken = await this.getAccessToken(); - if (accessToken == null) { - return; - } + if (repos.length === 0) return; let reposQuery = '['; reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); reposQuery += ']'; - const rsp = await this.server.fetchGraphql( - { - query: ` - mutation removeReposFromWorkspace { + const rsp = await this.fetch({ + query: ` + mutation removeReposFromWorkspace { remove_repositories_from_project( input: { project_id: "${workspaceId}", @@ -525,11 +454,9 @@ export class WorkspacesApi { ) { id } - } + } `, - }, - accessToken, - ); + }); if (!rsp.ok) { Logger.error(undefined, `Removing repositories from workspace failed: (${rsp.status}) ${rsp.statusText}`); @@ -549,4 +476,12 @@ export class WorkspacesApi { return json; } + + private async fetch(request: GraphQLRequest, init?: RequestInit) { + return this.connection.fetchGraphQL( + Uri.joinPath(this.connection.baseApiUri, 'api/projects/graphql').toString(), + request, + init, + ); + } } diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts index 0e1d055..8740767 100644 --- a/src/plus/workspaces/workspacesService.ts +++ b/src/plus/workspaces/workspacesService.ts @@ -9,7 +9,7 @@ import { showRepositoriesPicker } from '../../quickpicks/repositoryPicker'; import { SubscriptionState } from '../../subscription'; import { normalizePath } from '../../system/path'; import { openWorkspace, OpenWorkspaceLocation } from '../../system/utils'; -import type { ServerConnection } from '../subscription/serverConnection'; +import type { ServerConnection } from '../gk/serverConnection'; import type { SubscriptionChangeEvent } from '../subscription/subscriptionService'; import type { AddWorkspaceRepoDescriptor, @@ -56,9 +56,9 @@ export class WorkspacesService implements Disposable { constructor( private readonly container: Container, - private readonly server: ServerConnection, + private readonly connection: ServerConnection, ) { - this._workspacesApi = new WorkspacesApi(this.container, this.server); + this._workspacesApi = new WorkspacesApi(this.container, this.connection); this._workspacesPathProvider = getSupportedWorkspacesPathMappingProvider(); this._currentWorkspaceId = workspace.getConfiguration('gitkraken')?.get('workspaceId'); this._currentWorkspaceAutoAddSetting = diff --git a/src/uris/uriService.ts b/src/uris/uriService.ts index a932738..a4aec82 100644 --- a/src/uris/uriService.ts +++ b/src/uris/uriService.ts @@ -1,7 +1,7 @@ import type { Disposable, Event, Uri, UriHandler } from 'vscode'; import { EventEmitter, window } from 'vscode'; import type { Container } from '../container'; -import { AuthenticationUriPathPrefix } from '../plus/subscription/serverConnection'; +import { AuthenticationUriPathPrefix } from '../plus/gk/authenticationConnection'; import { log } from '../system/decorators/log'; // This service is in charge of registering a URI handler and handling/emitting URI events received by GitLens.