diff --git a/src/common/types.ts b/src/common/types.ts index 49eec539..b2b71924 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -288,6 +288,10 @@ export interface IAuth { password: string; } +export interface IStoredAuth { + account: string; + password: string; +} export interface ISvnLogEntryPath { /** full path from repo root */ _: string; diff --git a/src/repository.ts b/src/repository.ts index 6f82679e..680627ee 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -25,7 +25,8 @@ import { Status, SvnDepth, SvnUriAction, - ISvnPathChange + ISvnPathChange, + IStoredAuth } from "./common/types"; import { debounce, globalSequentialize, memoize, throttle } from "./decorators"; import { exists } from "./fs"; @@ -50,6 +51,7 @@ import { } from "./util"; import { match, matchAll } from "./util/globMatch"; import { RepositoryFilesWatcher } from "./watchers/repositoryFilesWatcher"; +import { keytar } from "./vscodeModules"; function shouldShowProgress(operation: Operation): boolean { switch (operation) { @@ -79,6 +81,7 @@ export class Repository implements IRemoteRepository { public needCleanUp: boolean = false; private remoteChangedUpdateInterval?: NodeJS.Timer; private deletedUris: Uri[] = []; + private canSaveAuth: boolean = false; private lastPromptAuth?: Thenable; @@ -919,6 +922,39 @@ export class Repository implements IRemoteRepository { return new PathNormalizer(this.repository.info); } + protected getCredentialServiceName() { + let key = "vscode.svn-scm"; + + const info = this.repository.info; + + if (info.repository && info.repository.root) { + key += ":" + info.repository.root; + } else if (info.url) { + key += ":" + info.url; + } + + return key; + } + + public async loadStoredAuths(): Promise> { + // Prevent multiple prompts for auth + if (this.lastPromptAuth) { + await this.lastPromptAuth; + } + return keytar.findCredentials(this.getCredentialServiceName()); + } + + public async saveAuth(): Promise { + if (this.canSaveAuth && this.username && this.password) { + await keytar.setPassword( + this.getCredentialServiceName(), + this.username, + this.password + ); + this.canSaveAuth = false; + } + } + public async promptAuth(): Promise { // Prevent multiple prompts for auth if (this.lastPromptAuth) { @@ -931,6 +967,7 @@ export class Repository implements IRemoteRepository { if (result) { this.username = result.username; this.password = result.password; + this.canSaveAuth = true; } this.lastPromptAuth = undefined; @@ -1002,11 +1039,14 @@ export class Repository implements IRemoteRepository { runOperation: () => Promise = () => Promise.resolve(null) ): Promise { let attempt = 0; + let accounts: IStoredAuth[] = []; while (true) { try { attempt++; - return await runOperation(); + const result = await runOperation(); + this.saveAuth(); + return result; } catch (err) { if ( err.svnErrorCode === svnErrorCodes.RepositoryIsLocked && @@ -1016,7 +1056,22 @@ export class Repository implements IRemoteRepository { await timeout(Math.pow(attempt, 2) * 50); } else if ( err.svnErrorCode === svnErrorCodes.AuthorizationFailed && - attempt <= 3 + attempt <= 1 + accounts.length + ) { + // First attempt load all stored auths + if (attempt === 1) { + accounts = await this.loadStoredAuths(); + } + + // each attempt, try a different account + const index = accounts.length - 1; + if (typeof accounts[index] !== "undefined") { + this.username = accounts[index].account; + this.password = accounts[index].password; + } + } else if ( + err.svnErrorCode === svnErrorCodes.AuthorizationFailed && + attempt <= 3 + accounts.length ) { const result = await this.promptAuth(); if (!result) { diff --git a/src/types/keytar.d.ts b/src/types/keytar.d.ts new file mode 100644 index 00000000..408921c8 --- /dev/null +++ b/src/types/keytar.d.ts @@ -0,0 +1,65 @@ +// Definitions by: Milan Burda , Brendan Forster , Hari Juturu +// Adapted from DefinitelyTyped: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/keytar/index.d.ts + +declare module "keytar" { + /** + * Get the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the password string. + */ + export declare function getPassword( + service: string, + account: string + ): Promise; + + /** + * Add the password for the service and account to the keychain. + * + * @param service The string service name. + * @param account The string account name. + * @param password The string password. + * + * @returns A promise for the set password completion. + */ + export declare function setPassword( + service: string, + account: string, + password: string + ): Promise; + + /** + * Delete the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the deletion status. True on success. + */ + export declare function deletePassword( + service: string, + account: string + ): Promise; + + /** + * Find a password for the service in the keychain. + * + * @param service The string service name. + * + * @returns A promise for the password string. + */ + export declare function findPassword(service: string): Promise; + + /** + * Find all accounts and passwords for `service` in the keychain. + * + * @param service The string service name. + * + * @returns A promise for the array of found credentials. + */ + export declare function findCredentials( + service: string + ): Promise>; +} diff --git a/src/vscodeModules.ts b/src/vscodeModules.ts index 17b1d31f..8a89f4e5 100644 --- a/src/vscodeModules.ts +++ b/src/vscodeModules.ts @@ -30,3 +30,4 @@ export const iconv = loadVSCodeModule( export const jschardet = loadVSCodeModule( "jschardet" ) as typeof import("jschardet"); +export const keytar = loadVSCodeModule("keytar") as typeof import("keytar");