Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ resources/rovo-dev/*

!.vscode/launch.json
!.vscode/tasks.json
/.idea/
/.idea/
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/atlclients/authInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export interface AuthInfo {

export interface OAuthInfo extends AuthInfo {
access: string;
refresh: string;
refresh?: string;
expirationDate?: number;
iat?: number;
recievedAt: number;
Expand Down
36 changes: 35 additions & 1 deletion src/atlclients/authStore.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@ export class CredentialManager implements Disposable {
private _refresher = new OAuthRefesher();
private negotiator: Negotiator;
private _refreshInFlight = new Map<string, Promise<void>>();
private mutex = new Mutex();
private _failedRefreshCache = new Map<string, { attemptsCount: number; lastAttemptAt: Date }>();

constructor(
Expand Down Expand Up @@ -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<void> {
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<string, AuthInfo>().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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
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<boolean> {
const productAuths = this._memStore.get(site.product.key);
let wasKeyDeleted = false;
let wasMemDeleted = false;
Expand Down
33 changes: 23 additions & 10 deletions src/atlclients/loginManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,6 @@ jest.mock('../analytics', () => ({
editedEvent: () => Promise.resolve(forceCastTo<TrackEvent>({})),
}));

jest.mock('./oauthDancer', () => ({
OAuthDancer: {
Instance: {
doDance: () => {},
doInitRemoteDance: () => {},
doFinishRemoteDance: () => {},
},
},
}));

jest.mock('../container', () => ({
Container: {
clientManager: {
Expand All @@ -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<AxiosInstance>(() =>
Promise.resolve({
headers: { 'x-ausername': 'whoknows' },
Expand All @@ -73,6 +75,17 @@ const mockedAxiosInstance = forceCastTo<AxiosInstance>(() =>
}),
);

jest.mock('./oauthDancer', () => ({
OAuthDancer: {
Instance: {
doDance: () => {},
doInitRemoteDance: () => {},
doFinishRemoteDance: () => {},
getAxiosInstance: () => mockedAxiosInstance,
},
},
}));

describe('LoginManager', () => {
let loginManager: LoginManager;
let credentialManager: CredentialManager;
Expand Down
167 changes: 166 additions & 1 deletion src/atlclients/loginManager.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -28,13 +32,16 @@ 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'];

export class LoginManager {
private _dancer: OAuthDancer = OAuthDancer.Instance;
private _jiraAuthenticator: JiraAuthenticator;
private _bitbucketAuthenticator: BitbucketAuthenticator;
private _bitbucketResponseHandler: BitbucketResponseHandler;

constructor(
private _credentialManager: CredentialManager,
Expand All @@ -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
Expand Down Expand Up @@ -140,7 +154,158 @@ export class LoginManager {
}
}

private async getOAuthSiteDetails(
// Look for https://x-token-auth:<token>@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<string | null> {
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<string>((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<string | null> {
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<boolean> {
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<boolean> {
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,
Expand Down
Loading
Loading