diff --git a/.gitignore b/.gitignore index df8014764..644ddd657 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ resources/rovo-dev/* !.vscode/launch.json !.vscode/tasks.json -/.idea/ \ No newline at end of file +/.idea/ diff --git a/package-lock.json b/package-lock.json index c0af1179a..37ff455f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31126,6 +31126,19 @@ "retry": "0.13.1" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/async-mutex/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://packages.atlassian.com/api/npm/npm-remote/asynckit/-/asynckit-0.4.0.tgz", diff --git a/package.json b/package.json index 7edbc341a..bd7dd4b86 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,10 @@ "category": "Rovo Dev", "enablement": "atlascode:rovoDevEnabled" }, + { + "command": "atlascode.authenticateWithBitbucketToken", + "title": "Atlassian: Authenticate with Bitbucket Token" + }, { "command": "atlascode.rovodev.logout", "title": "Sign out", @@ -1652,6 +1656,12 @@ "default": true, "description": "Shows the help explorer treeview", "scope": "window" + }, + "atlascode.disableOnboarding": { + "type": "boolean", + "default": false, + "description": "Hide initial onboarding and help screens", + "scope": "window" } } } @@ -1730,6 +1740,7 @@ "@vscode/codicons": "^0.0.44", "@vscode/webview-ui-toolkit": "^1.4.0", "adm-zip": "^0.5.16", + "async-mutex": "^0.5.0", "awesome-debounce-promise": "^2.1.0", "axios": "^1.12.0", "axios-curlirize": "^2.0.0", diff --git a/src/atlclients/authInfo.ts b/src/atlclients/authInfo.ts index b29e366bc..9b76c5ba8 100644 --- a/src/atlclients/authInfo.ts +++ b/src/atlclients/authInfo.ts @@ -74,7 +74,7 @@ export interface AuthInfo { export interface OAuthInfo extends AuthInfo { access: string; - refresh: string; + refresh?: string; expirationDate?: number; iat?: number; recievedAt: number; diff --git a/src/atlclients/authStore.ts b/src/atlclients/authStore.ts index aadaef2a5..4206e36d3 100644 --- a/src/atlclients/authStore.ts +++ b/src/atlclients/authStore.ts @@ -1,3 +1,4 @@ +import { Mutex } from 'async-mutex'; import crypto from 'crypto'; import PQueue from 'p-queue'; import { Disposable, Event, EventEmitter, ExtensionContext, version, window } from 'vscode'; @@ -55,6 +56,7 @@ export class CredentialManager implements Disposable { private _refresher = new OAuthRefesher(); private negotiator: Negotiator; private _refreshInFlight = new Map>(); + private mutex = new Mutex(); private _failedRefreshCache = new Map(); constructor( @@ -280,6 +282,29 @@ export class CredentialManager implements Disposable { } } + /** + * Saves the auth info to both the in-memory store and the secretstorage for Bitbucket API key login + */ + public async saveAuthInfoBBToken(site: DetailedSiteInfo, info: AuthInfo, id: number): Promise { + Logger.debug( + `[id=${id}] Saving auth info for site: ${site.baseApiUrl} credentialID: ${site.credentialId} using BB token`, + ); + const productAuths = this._memStore.get(site.product.key); + Logger.debug(`[id=${id}] productAuths: ${productAuths}`); + if (!productAuths) { + this._memStore.set(site.product.key, new Map().set(site.credentialId, info)); + Logger.debug(`[id=${id}] productAuths is empty so setting it to the new auth info in memstore`); + } else { + productAuths.set(site.credentialId, info); + this._memStore.set(site.product.key, productAuths); + Logger.debug( + `[id=${id}] productAuths is not empty so setting it to productAuth: ${productAuths}in memstore`, + ); + } + Logger.debug(`[id=${id}] Calling saveAuthInfo for site: ${site.baseApiUrl} credentialID: ${site.credentialId}`); + await this.saveAuthInfo(site, info); + } + private async getAuthInfoForProductAndCredentialId( site: DetailedSiteInfo, allowCache: boolean, @@ -557,7 +582,7 @@ export class CredentialManager implements Disposable { const provider: OAuthProvider | undefined = oauthProviderForSite(site); - if (provider && credentials) { + if (provider && credentials && credentials.refresh) { const tokenResponse = await this._refresher.getNewTokens(provider, credentials.refresh); if (tokenResponse.tokens) { const newTokens = tokenResponse.tokens; @@ -591,6 +616,15 @@ export class CredentialManager implements Disposable { * Removes an auth item from both the in-memory store and the secretstorage. */ public async removeAuthInfo(site: DetailedSiteInfo): Promise { + return this.mutex.runExclusive(() => { + return this.removeAuthInfoForProductAndCredentialId(site); + }); + } + + /** + * Removes an auth item from both the in-memory store and the secretstorage (internal implementation). + */ + private async removeAuthInfoForProductAndCredentialId(site: DetailedSiteInfo): Promise { const productAuths = this._memStore.get(site.product.key); let wasKeyDeleted = false; let wasMemDeleted = false; diff --git a/src/atlclients/loginManager.test.ts b/src/atlclients/loginManager.test.ts index cc49468e0..384d4389a 100644 --- a/src/atlclients/loginManager.test.ts +++ b/src/atlclients/loginManager.test.ts @@ -35,16 +35,6 @@ jest.mock('../analytics', () => ({ editedEvent: () => Promise.resolve(forceCastTo({})), })); -jest.mock('./oauthDancer', () => ({ - OAuthDancer: { - Instance: { - doDance: () => {}, - doInitRemoteDance: () => {}, - doFinishRemoteDance: () => {}, - }, - }, -})); - jest.mock('../container', () => ({ Container: { clientManager: { @@ -56,9 +46,21 @@ jest.mock('../container', () => ({ removeSite: jest.fn(), addOrUpdateSite: jest.fn(), }, + config: { + enableCurlLogging: false, + }, }, })); +jest.mock('./strategyCrypto', () => { + return { + createVerifier: jest.fn(() => 'verifier'), + base64URLEncode: jest.fn(() => 'base64URLEncode'), + sha256: jest.fn(() => 'sha256'), + basicAuth: jest.fn(() => 'basicAuth'), + }; +}); + const mockedAxiosInstance = forceCastTo(() => Promise.resolve({ headers: { 'x-ausername': 'whoknows' }, @@ -73,6 +75,17 @@ const mockedAxiosInstance = forceCastTo(() => }), ); +jest.mock('./oauthDancer', () => ({ + OAuthDancer: { + Instance: { + doDance: () => {}, + doInitRemoteDance: () => {}, + doFinishRemoteDance: () => {}, + getAxiosInstance: () => mockedAxiosInstance, + }, + }, +})); + describe('LoginManager', () => { let loginManager: LoginManager; let credentialManager: CredentialManager; diff --git a/src/atlclients/loginManager.ts b/src/atlclients/loginManager.ts index 020e46afa..bbac1160e 100644 --- a/src/atlclients/loginManager.ts +++ b/src/atlclients/loginManager.ts @@ -1,3 +1,7 @@ +import * as cp from 'child_process'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; import { Container } from 'src/container'; import * as vscode from 'vscode'; @@ -28,6 +32,8 @@ import { CredentialManager } from './authStore'; import { BitbucketAuthenticator } from './bitbucketAuthenticator'; import { JiraAuthentictor as JiraAuthenticator } from './jiraAuthenticator'; import { OAuthDancer } from './oauthDancer'; +import { BitbucketResponseHandler } from './responseHandlers/BitbucketResponseHandler'; +import { strategyForProvider } from './strategy'; const CLOUD_TLDS = ['.atlassian.net', '.jira.com']; @@ -35,6 +41,7 @@ export class LoginManager { private _dancer: OAuthDancer = OAuthDancer.Instance; private _jiraAuthenticator: JiraAuthenticator; private _bitbucketAuthenticator: BitbucketAuthenticator; + private _bitbucketResponseHandler: BitbucketResponseHandler; constructor( private _credentialManager: CredentialManager, @@ -43,6 +50,13 @@ export class LoginManager { ) { this._bitbucketAuthenticator = new BitbucketAuthenticator(); this._jiraAuthenticator = new JiraAuthenticator(); + // Initialize BitbucketResponseHandler + const axiosInstance = this._dancer.getAxiosInstance(); + this._bitbucketResponseHandler = new BitbucketResponseHandler( + strategyForProvider(OAuthProvider.BitbucketCloud), + this._analyticsClient, + axiosInstance, + ); } // this is *only* called when login buttons are clicked by the user @@ -140,7 +154,158 @@ export class LoginManager { } } - private async getOAuthSiteDetails( + // Look for https://x-token-auth:@bitbucket.org pattern + private extractTokenFromGitRemoteRegex(line: string): string | null { + const tokenMatch = line.match(/https:\/\/x-token-auth:([^@]+)@bitbucket\.org/); + if (tokenMatch && tokenMatch[1]) { + Logger.debug('Auth token found in git remote'); + return tokenMatch[1]; + } + return null; + } + + /** + * Extracts auth token from git remote URL + * @returns The auth token or null if not found + */ + private async getAuthTokenFromGitRemote(): Promise { + try { + // Get the workspace folder path + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + Logger.warn('No workspace folder found'); + return null; + } + const workspacePath = workspaceFolders[0].uri.fsPath; + // Execute git remote -v command + const gitCommand = 'git remote -v'; + const result = await new Promise((resolve, reject) => { + cp.exec(gitCommand, { cwd: workspacePath }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); + // Parse the output to find the token + const remoteLines = result.split('\n'); + for (const line of remoteLines) { + const token = this.extractTokenFromGitRemoteRegex(line); + if (token) { + Logger.debug('Auth token found in git remote'); + return token; + } + } + Logger.warn('No auth token found in git remote'); + return null; + } catch (error) { + Logger.error(error, 'Error extracting auth token from git remote'); + return null; + } + } + + /** + * Extracts auth token from git remote URL + * @returns The auth token or null if not found + */ + private async getAuthTokenFromHomeGitCredentials(): Promise { + try { + const credentialsFullPath = join(homedir(), '.git-credentials'); + const credentialsContents = await readFile(credentialsFullPath, 'utf-8'); + const token = this.extractTokenFromGitRemoteRegex(credentialsContents); + if (token) { + Logger.debug('Auth token found in git credentials file'); + return token; + } + Logger.warn('No auth token found in git credentials file'); + return null; + } catch (error) { + Logger.error(error, 'Error extracting auth token from git remote'); + return null; + } + } + + private async refreshBitbucketToken(siteDetails: DetailedSiteInfo): Promise { + const token = await this._credentialManager.getAuthInfo(siteDetails, false); + if (token) { + return this.authenticateWithBitbucketToken(true); + } + return false; + } + + // Add a new method for token-based authentication + public async authenticateWithBitbucketToken(refresh: boolean = false): Promise { + try { + const [tokenRemote, tokenCredentialsFile] = await Promise.allSettled([ + this.getAuthTokenFromGitRemote(), + this.getAuthTokenFromHomeGitCredentials(), + ]); + + let token: string | null = null; + + if (tokenRemote.status === 'fulfilled' && tokenRemote.value) { + token = tokenRemote.value; + } else if (tokenCredentialsFile.status === 'fulfilled' && tokenCredentialsFile.value) { + token = tokenCredentialsFile.value; + } + + if (!token) { + Logger.warn('No hardcoded Bitbucket auth token found'); + return false; + } + Logger.debug('Authenticating with Bitbucket using auth token'); + // Use the BitbucketResponseHandler to get user info + const userData = await this._bitbucketResponseHandler.user(token); + const [oAuthSiteDetails] = await this.getOAuthSiteDetails( + ProductBitbucket, + OAuthProvider.BitbucketCloud, + userData.id, + [ + { + id: OAuthProvider.BitbucketCloud, + name: ProductBitbucket.name, + scopes: [], + avatarUrl: '', + url: 'https://api.bitbucket.org/2.0', + }, + ], + ); + const oAuthInfo: OAuthInfo = { + access: token, + refresh: '', + recievedAt: Date.now(), + user: { + id: userData.id, + displayName: userData.displayName, + email: userData.email, + avatarUrl: userData.avatarUrl, + }, + state: AuthInfoState.Valid, + }; + await this._credentialManager.saveAuthInfo(oAuthSiteDetails, oAuthInfo); + setTimeout( + () => { + this.refreshBitbucketToken(oAuthSiteDetails); + }, + 2 * 60 * 60 * 1000, + ); + if (!refresh) { + this._siteManager.addSites([oAuthSiteDetails]); + // Fire authenticated event + authenticatedEvent(oAuthSiteDetails, false).then((e) => { + this._analyticsClient.sendTrackEvent(e); + }); + Logger.info('Successfully authenticated with Bitbucket using auth token'); + } + return true; + } catch (e) { + Logger.error(e, 'Error authenticating with Bitbucket token'); + return false; + } + } + + public async getOAuthSiteDetails( product: Product, provider: OAuthProvider, userId: string, diff --git a/src/atlclients/oauthDancer.ts b/src/atlclients/oauthDancer.ts index fde4f18c7..a9946466f 100644 --- a/src/atlclients/oauthDancer.ts +++ b/src/atlclients/oauthDancer.ts @@ -299,4 +299,8 @@ export class OAuthDancer implements Disposable { this._shutdownCheck = setInterval(this.maybeShutdown, this._shutdownCheckInterval); } + + public getAxiosInstance(): AxiosInstance { + return this._axios; + } } diff --git a/src/atlclients/responseHandlers/BitbucketResponseHandler.ts b/src/atlclients/responseHandlers/BitbucketResponseHandler.ts index bcc1707f3..c85f83923 100644 --- a/src/atlclients/responseHandlers/BitbucketResponseHandler.ts +++ b/src/atlclients/responseHandlers/BitbucketResponseHandler.ts @@ -33,7 +33,7 @@ export class BitbucketResponseHandler extends ResponseHandler { } } - public async user(accessToken: string, resource: AccessibleResource): Promise { + public async user(accessToken: string): Promise { try { const userResponse = await this.axios(this.strategy.profileUrl(), { method: 'GET', diff --git a/src/commands.ts b/src/commands.ts index ce7f05078..ea1aa9a38 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -429,6 +429,9 @@ export function registerRovoDevCommands(vscodeContext: ExtensionContext) { RovodevCommands.OpenRovoDevLogFile, async () => await openRovoDevConfigFile('rovodev.log'), ), + commands.registerCommand(Commands.AuthenticateWithBitbucketToken, () => { + Container.authenticateWithBitbucketToken(); + }), commands.registerCommand( RovodevCommands.RestartProcess, async () => await Container.rovodevWebviewProvider.executeRestartProcess(), diff --git a/src/config/model.ts b/src/config/model.ts index 91e0cf9bc..5c5e6ca0d 100644 --- a/src/config/model.ts +++ b/src/config/model.ts @@ -40,6 +40,7 @@ export interface IConfig { enableCurlLogging: boolean; enableHttpsTunnel: boolean; helpExplorerEnabled: boolean; + disableOnboarding: boolean; rovodev: RovoDevConfig; } @@ -294,5 +295,6 @@ export const emptyConfig: IConfig = { enableCurlLogging: false, enableHttpsTunnel: false, helpExplorerEnabled: true, + disableOnboarding: false, rovodev: emptyRovoDevConfig, }; diff --git a/src/constants.ts b/src/constants.ts index dd85d5ac6..fc578e6f2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -98,6 +98,7 @@ export const enum Commands { JiraAPITokenLogin = 'atlascode.jira.apiTokenLogin', ExpandCreateWorkItemWebview = 'atlascode.jira.expandCreateWorkItem', CopyImageElement = 'atlascode.jira.copyImageElement', + AuthenticateWithBitbucketToken = 'atlascode.authenticateWithBitbucketToken', // Debug mode-only commands DebugQuickCommand = 'atlascode.debug.quickCommand', diff --git a/src/container.ts b/src/container.ts index 1c0aea637..cebfd80be 100644 --- a/src/container.ts +++ b/src/container.ts @@ -489,6 +489,8 @@ export class Container { )), ); this._context.subscriptions.push((this._jiraActiveIssueStatusBar = new JiraActiveIssueStatusBar(bbCtx))); + // Authenticate with Bitbucket when the extension opens; safely no-ops if no token is found + Container.authenticateWithBitbucketToken(); this._context.subscriptions.push(new BitbucketCloudPullRequestLinkProvider()); // It seems to take a bit of time for VS Code to initialize git, if we try and find repos before that completes @@ -592,6 +594,10 @@ export class Container { return this._settingsWebviewFactory; } + static async authenticateWithBitbucketToken() { + Container.loginManager.authenticateWithBitbucketToken(); + } + private static _pullRequestDetailsWebviewFactory: MultiWebview; public static get pullRequestDetailsWebviewFactory() { return this._pullRequestDetailsWebviewFactory; diff --git a/src/extension.ts b/src/extension.ts index d990c5118..7f83f7c0e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -103,7 +103,7 @@ export async function activate(context: ExtensionContext) { Container.clientManager.requestSite(site); }); - if (!RovodevStaticConfig.isBBY) { + if (!RovodevStaticConfig.isBBY && !Container.config.disableOnboarding) { if (previousVersion === undefined) { commands.executeCommand(Commands.ShowOnboardingFlow); } else { diff --git a/src/uriHandler/atlascodeUriHandler.ts b/src/uriHandler/atlascodeUriHandler.ts index 18e20a755..a4b6a8a92 100644 --- a/src/uriHandler/atlascodeUriHandler.ts +++ b/src/uriHandler/atlascodeUriHandler.ts @@ -21,6 +21,9 @@ export class AtlascodeUriHandler extends Disposable implements UriHandler { this.singleton = new AtlascodeUriHandler(analyticsApi, [ new BasicUriHandler('openSettings', () => Container.settingsWebviewFactory.createOrShow()), new BasicUriHandler('extension', () => Promise.resolve(Container.focus())), + new BasicUriHandler('authenticateWithBitbucketToken', async () => { + await Container.authenticateWithBitbucketToken(); + }), new OpenPullRequestUriHandler(bitbucketHelper), new CloneRepositoryUriHandler(bitbucketHelper), new OpenOrWorkOnJiraIssueUriHandler('openJiraIssue'), diff --git a/src/views/gitContentProvider.ts b/src/views/gitContentProvider.ts index 841e1c6e7..8a61d1641 100644 --- a/src/views/gitContentProvider.ts +++ b/src/views/gitContentProvider.ts @@ -56,7 +56,7 @@ export class GitContentProvider implements vscode.TextDocumentContentProvider { })(), ]); } catch { - vscode.window.showErrorMessage( + console.error( `We couldn't find ${path} at commit ${commitHash}. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push.`, ); } diff --git a/src/webview/ExplorerFocusManager.ts b/src/webview/ExplorerFocusManager.ts index 230ef38ca..fb4db49af 100644 --- a/src/webview/ExplorerFocusManager.ts +++ b/src/webview/ExplorerFocusManager.ts @@ -1,7 +1,7 @@ import vscode, { Disposable, Event, EventEmitter } from 'vscode'; -import { ProductBitbucket, ProductJira } from '../atlclients/authInfo'; -import { AssignedJiraItemsViewId, PullRequestTreeViewId } from '../constants'; +import { ProductJira } from '../atlclients/authInfo'; +import { AssignedJiraItemsViewId } from '../constants'; import { Container } from '../container'; import { SitesAvailableUpdateEvent } from '../siteManager'; @@ -52,8 +52,6 @@ export class ExplorerFocusManager extends Disposable { if (!isRovoDevVisible) { vscode.commands.executeCommand(`${AssignedJiraItemsViewId}.focus`); } - } else if (updateEvent.product.key === ProductBitbucket.key) { - vscode.commands.executeCommand(`${PullRequestTreeViewId}.focus`); } } }