src/desktop/accounts/account_service.ts (224 lines of code) (raw):

import assert from 'assert'; import { isDeepStrictEqual } from 'util'; import { EventEmitter, ExtensionContext, Event } from 'vscode'; import { log } from '../../common/log'; import { hasPresentKey } from '../utils/has_present_key'; import { notNullOrUndefined } from '../../common/utils/not_null_or_undefined'; import { removeTrailingSlash } from '../utils/remove_trailing_slash'; import { uniq } from '../utils/uniq'; import { Account, makeAccountId, OAuthAccount, serializeAccountSafe, TokenAccount, } from '../../common/platform/gitlab_account'; import { readSingleLineFromFile, setEnvVariableFromFile } from '../utils/env_var_helpers'; import { UserFriendlyError } from '../../common/errors/user_friendly_error'; import { Credentials } from './credentials'; interface TokenSecret { token: string; } interface OAuthSecret { token: string; refreshToken: string; expiresAtTimestampInSeconds: number; } type Secret = TokenSecret | OAuthSecret; /** Secrets by Account ID (<accountId, Secret | undefined>) */ type SecretsForAccounts = Record<string, Secret | undefined>; type AccountWithoutSecret = | Omit<TokenAccount, keyof TokenSecret> | Omit<OAuthAccount, keyof OAuthSecret>; const getEnvironmentVariables = (): Credentials | undefined => { const { GITLAB_WORKFLOW_INSTANCE_URL, GITLAB_WORKFLOW_TOKEN } = process.env; if (!GITLAB_WORKFLOW_INSTANCE_URL || !GITLAB_WORKFLOW_TOKEN) return undefined; return { instanceUrl: removeTrailingSlash(GITLAB_WORKFLOW_INSTANCE_URL), token: GITLAB_WORKFLOW_TOKEN, }; }; const ACCOUNTS_KEY = 'glAccounts'; const SECRETS_KEY = 'gitlab-tokens'; const getEnvAccount = (): Account | undefined => { const credentials = getEnvironmentVariables(); if (!credentials) return undefined; return { id: makeAccountId(credentials.instanceUrl, 'environment-variables'), username: 'environment_variable_credentials', ...credentials, type: 'token', }; }; const getSecrets = async ( context: ExtensionContext, ): Promise<Record<string, Secret | undefined>> => { const stringTokens = await context.secrets.get(SECRETS_KEY); return stringTokens ? JSON.parse(stringTokens) : {}; }; const splitAccount = ( account: Account, ): { accountWithoutSecret: AccountWithoutSecret; secret: Secret } => { if (account.type === 'token') { const { token, ...accountWithoutSecret } = account; return { accountWithoutSecret, secret: { token } }; } if (account.type === 'oauth') { const { token, refreshToken, expiresAtTimestampInSeconds, ...accountWithoutSecret } = account; return { accountWithoutSecret, secret: { token, refreshToken, expiresAtTimestampInSeconds } }; } throw new Error(`Unexpected account type for account ${JSON.stringify(account)}`); }; const setEnvVariablesFromFile = async () => { const { GITLAB_WORKFLOW_TOKEN_FILE, GITLAB_WORKFLOW_TOKEN } = process.env; if (!GITLAB_WORKFLOW_TOKEN && GITLAB_WORKFLOW_TOKEN_FILE) { try { await setEnvVariableFromFile( 'GITLAB_WORKFLOW_TOKEN', GITLAB_WORKFLOW_TOKEN_FILE, readSingleLineFromFile, ); } catch (error) { log.error(error); } } }; export class AccountService { context?: ExtensionContext; secrets: SecretsForAccounts = {}; #onDidChangeEmitter = new EventEmitter<void>(); async init(context: ExtensionContext): Promise<void> { this.context = context; await setEnvVariablesFromFile(); try { this.secrets = await getSecrets(context); } catch (error) { throw new UserFriendlyError( `GitLab Workflow can't access the OS Keychain. See this [existing issue](https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/issues/580) for more information.`, error, ); } } get onDidChange(): Event<void> { return this.#onDidChangeEmitter.event; } get #accountMap(): Record<string, AccountWithoutSecret | undefined> { assert(this.context); return this.context.globalState.get(ACCOUNTS_KEY, {}); } getInstanceUrls(): string[] { return uniq(this.getAllAccounts().map(a => a.instanceUrl)); } /** * This method returns account for given instance URL or undefined if there is no such account. * If there are multiple accounts for the instance we'll log warning and use the first one. * @deprecated This method is used only for compatibility with legacy single-account logic. Handle the possibility of multiple accounts for the same instance! * @param instanceUrl * @returns The first account for this instance URL */ getOneAccountForInstance(instanceUrl: string): Account | undefined { const accounts = this.getAllAccounts().filter(a => a.instanceUrl === instanceUrl); if (accounts.length > 1) log.warn( `[AccountService] There are multiple accounts for ${instanceUrl}.` + `Extension will use the one for user ${accounts[0].username}`, ); return accounts[0]; } getAllAccounts(): Account[] { return [...this.#getRemovableAccountsWithTokens(), getEnvAccount()].filter(notNullOrUndefined); } async addAccount(account: Account) { assert(this.context); // worst-case scenario we replace one set of valid credentials with another // we can refresh the cache await this.reloadCache(); const accountMap = this.#accountMap; if (accountMap[account.id]) { throw new Error( `Account for instance ${account.instanceUrl} and user ${account.username} already exists. The extension ignored the request to re-add it. You can remove the account with the "GitLab: Remove Account from VS Code" command and add it again.`, ); } const { secret, accountWithoutSecret } = splitAccount(account); await this.#storeSecret(account.id, secret); await this.context.globalState.update(ACCOUNTS_KEY, { ...accountMap, [account.id]: accountWithoutSecret, }); this.#onDidChangeEmitter.fire(); } async #validateSecretIsUpToDate(accountId: string) { const { oldSecrets, newSecrets } = await this.reloadCache(); assert.deepStrictEqual( oldSecrets[accountId], newSecrets[accountId], `Task cancelled because the GitLab token for account ${accountId} stored in your keychain has changed. ` + '(Another instance of VS Code or OS synchronizing keychains changed it.) Retry the task. ' + `Old: ${JSON.stringify(oldSecrets[accountId])}, ` + `New: ${JSON.stringify(newSecrets[accountId])}`, ); } async #removeToken(accountId: string) { assert(this.context); await this.#validateSecretIsUpToDate(accountId); // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.secrets[accountId]; await this.context.secrets.store(SECRETS_KEY, JSON.stringify(this.secrets)); } async #storeSecret(accountId: string, secret: Secret) { assert(this.context); await this.#validateSecretIsUpToDate(accountId); const secrets = { ...this.secrets, [accountId]: secret }; await this.context.secrets.store(SECRETS_KEY, JSON.stringify(secrets)); this.secrets = secrets; } getAccount(accountId: string): Account | undefined { const result = this.getAllAccounts().find(a => a.id === accountId); return result; } async updateAccountSecret(account: Account) { log.info(`[AccountService][auth] Updating account secret for ${serializeAccountSafe(account)}`); assert(this.context); const { secret } = splitAccount(account); await this.#storeSecret(account.id, secret); log.info( `[AccountService][auth] Successfully updated account secret for ${serializeAccountSafe(account)}`, ); this.#onDidChangeEmitter.fire(); } /** Loads the latest secrets from OS Keychain, useful when other VS Code Windows manipulates the secrets. */ async reloadCache(): Promise<{ oldSecrets: SecretsForAccounts; newSecrets: SecretsForAccounts }> { assert(this.context); const oldSecrets = this.secrets; const newSecrets = await getSecrets(this.context); this.secrets = newSecrets; if (!isDeepStrictEqual(Object.keys(oldSecrets), Object.keys(newSecrets))) { log.info( `[AccountService][auth] Token cache mismatch detected between local memory and OS Keychain.\n` + `Cached account IDs: ${Object.keys(oldSecrets)}, ` + `OS Keychain account IDs: ${Object.keys(newSecrets)}`, ); this.#onDidChangeEmitter.fire(); } return { oldSecrets, newSecrets }; } async removeAccount(accountId: string) { assert(this.context); const accountMap = this.#accountMap; // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete accountMap[accountId]; await this.context.globalState.update(ACCOUNTS_KEY, accountMap); await this.#removeToken(accountId); this.#onDidChangeEmitter.fire(); } async getUpToDateRemovableAccounts(): Promise<AccountWithoutSecret[]> { await this.reloadCache(); return this.#getRemovableAccounts(); } #getRemovableAccounts(): AccountWithoutSecret[] { return Object.values(this.#accountMap).filter(notNullOrUndefined); } #getRemovableAccountsWithTokens(): Account[] { const accountsWithMaybeTokens = this.#getRemovableAccounts().map(a => ({ ...a, token: undefined, ...this.secrets[a.id], })); accountsWithMaybeTokens .filter(a => !a.token) .forEach(a => log.error( `[AccountService][auth] Account for instance ${a.instanceUrl} and user ${a.username} is missing a token in secret storage. Remove the account and add it again.`, ), ); return accountsWithMaybeTokens.filter((a): a is Account => hasPresentKey('token')(a)); } } export const accountService: AccountService = new AccountService();