Skip to content

Commit e7c308a

Browse files
Add hardcoded site flow to the extension
1 parent 64ac971 commit e7c308a

11 files changed

+362
-24
lines changed

package.json

+44
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@
100100
},
101101
"contributes": {
102102
"commands": [
103+
{
104+
"command": "atlascode.authenticateWithBitbucketToken",
105+
"title": "Atlasian: Authenticate with Bitbucket Token"
106+
},
103107
{
104108
"command": "atlascode.jira.todoIssue",
105109
"title": "TO DO"
@@ -1484,6 +1488,46 @@
14841488
"default": true,
14851489
"description": "Shows the help explorer treeview",
14861490
"scope": "window"
1491+
},
1492+
"atlascode.internal.hardcodedSite": {
1493+
"type": "object",
1494+
"properties": {
1495+
"product": {
1496+
"enum": [
1497+
"bitbucket"
1498+
]
1499+
},
1500+
"host": {
1501+
"type": "string"
1502+
},
1503+
"credentialsPath": {
1504+
"type": "string"
1505+
},
1506+
"credentialsFormat": {
1507+
"enum": [
1508+
"git-remote",
1509+
"self"
1510+
],
1511+
"default": "git-remote"
1512+
},
1513+
"authHeader": {
1514+
"enum": [
1515+
"bearer",
1516+
"basic"
1517+
],
1518+
"default": "bearer"
1519+
},
1520+
"isCloud": {
1521+
"type": "boolean",
1522+
"default": true
1523+
},
1524+
"hasResolutionField": {
1525+
"type": "boolean",
1526+
"default": true
1527+
}
1528+
},
1529+
"description": "The hardcoded site to use for Bitbucket authentication",
1530+
"scope": "window"
14871531
}
14881532
}
14891533
}

src/atlclients/authInfo.ts

+11-7
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ export interface BasicAuthInfo extends AuthInfoCommon {
9898
password: string;
9999
}
100100

101-
export type AuthInfo = NoAuthInfo | OAuthInfo | BasicAuthInfo | PATAuthInfo;
101+
export interface HardCodedAuthInfo extends AuthInfoCommon {
102+
type: 'hardcoded';
103+
token: string;
104+
authHeader: 'bearer' | 'basic';
105+
}
106+
107+
export type AuthInfo = NoAuthInfo | OAuthInfo | BasicAuthInfo | PATAuthInfo | HardCodedAuthInfo;
102108

103109
export interface UserInfo {
104110
id: string;
@@ -273,13 +279,11 @@ export function isPATAuthInfo(a: AuthInfo | undefined): a is PATAuthInfo {
273279
export function getSecretForAuthInfo(info: AuthInfo): string {
274280
if (isOAuthInfo(info)) {
275281
return info.access + info.refresh;
276-
}
277-
278-
if (isBasicAuthInfo(info)) {
282+
} else if (isBasicAuthInfo(info)) {
279283
return info.password;
280-
}
281-
282-
if (isPATAuthInfo(info)) {
284+
} else if (isPATAuthInfo(info)) {
285+
return info.token;
286+
} else if (info.type === 'hardcoded') {
283287
return info.token;
284288
}
285289

src/atlclients/authStore.ts

+6
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ export class CredentialManager implements Disposable {
308308
public async refreshAccessToken(site: DetailedSiteInfo): Promise<boolean> {
309309
const credentials = await this.getAuthInfo(site);
310310
if (!isOAuthInfo(credentials)) {
311+
if (credentials?.type === 'hardcoded') {
312+
const hardcodedSite = Container.calculateHardcodedSite();
313+
if (hardcodedSite && hardcodedSite.host === site.host) {
314+
return await Container.loginManager.authenticateHardcodedSite(hardcodedSite, credentials);
315+
}
316+
}
311317
return false;
312318
}
313319
Logger.debug(`refreshingAccessToken for ${site.baseApiUrl} credentialID: ${site.credentialId}`);

src/atlclients/clientManager.ts

+18-14
Original file line numberDiff line numberDiff line change
@@ -119,27 +119,31 @@ export class ClientManager implements Disposable {
119119
let result: BitbucketApi;
120120
if (site.isCloud) {
121121
result = {
122-
repositories: isOAuthInfo(info)
123-
? new CloudRepositoriesApi(this.createOAuthHTTPClient(site, info))
124-
: undefined!,
125-
pullrequests: isOAuthInfo(info)
126-
? new CloudPullRequestApi(this.createOAuthHTTPClient(site, info))
127-
: undefined!,
128-
issues: isOAuthInfo(info)
129-
? new BitbucketIssuesApiImpl(this.createOAuthHTTPClient(site, info))
130-
: undefined!,
131-
pipelines: isOAuthInfo(info)
132-
? new PipelineApiImpl(this.createOAuthHTTPClient(site, info))
133-
: undefined!,
122+
repositories:
123+
isOAuthInfo(info) || info.type === 'hardcoded'
124+
? new CloudRepositoriesApi(this.createOAuthHTTPClient(site, info))
125+
: undefined!,
126+
pullrequests:
127+
isOAuthInfo(info) || info.type === 'hardcoded'
128+
? new CloudPullRequestApi(this.createOAuthHTTPClient(site, info))
129+
: undefined!,
130+
issues:
131+
isOAuthInfo(info) || info.type === 'hardcoded'
132+
? new BitbucketIssuesApiImpl(this.createOAuthHTTPClient(site, info))
133+
: undefined!,
134+
pipelines:
135+
isOAuthInfo(info) || info.type === 'hardcoded'
136+
? new PipelineApiImpl(this.createOAuthHTTPClient(site, info))
137+
: undefined!,
134138
};
135139
} else {
136140
result = {
137141
repositories:
138-
isBasicAuthInfo(info) || isPATAuthInfo(info)
142+
isBasicAuthInfo(info) || isPATAuthInfo(info) || info.type === 'hardcoded'
139143
? new ServerRepositoriesApi(this.createHTTPClient(site, info))
140144
: undefined!,
141145
pullrequests:
142-
isBasicAuthInfo(info) || isPATAuthInfo(info)
146+
isBasicAuthInfo(info) || isPATAuthInfo(info) || info.type === 'hardcoded'
143147
? new ServerPullRequestApi(this.createHTTPClient(site, info))
144148
: undefined!,
145149
issues: undefined,

src/atlclients/getUserForBBToken.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Container } from '../container';
2+
import { getAxiosInstance } from '../jira/jira-client/providers';
3+
import { OAuthProvider } from './authInfo';
4+
import { BitbucketResponseHandler } from './responseHandlers/BitbucketResponseHandler';
5+
import { strategyForProvider } from './strategy';
6+
7+
/**
8+
* This is a very oppurtunistic function. This uses different parts of the code written for different purposes
9+
* and combines it together instead of rewriting the same code. This ensures that this function will evolve as the code changes.
10+
*
11+
* Strategy is something catered towarda OAuth flow but their profile / user URLs are universal. So, we use it
12+
* to get the URls. Then, `BitbucketResponseHandler` is written exactly to extract the user info given an oauth token.
13+
* But the way it is implemented, it does not matter; we can send our auth header ourselves. And so we do.
14+
*
15+
* `getAxiosInstance` and `Container.analyticsClient` are generic substritutes for their types throughout the code
16+
* and so we use them as well.
17+
*/
18+
export function getUserForBBToken(authHeader: string) {
19+
const axiosInstance = getAxiosInstance();
20+
21+
const handler = new BitbucketResponseHandler(
22+
strategyForProvider(OAuthProvider.BitbucketCloud),
23+
Container.analyticsClient,
24+
axiosInstance,
25+
);
26+
27+
return handler.user(authHeader);
28+
}

src/atlclients/loginManager.ts

+143
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
import { readFile } from 'fs/promises';
12
import * as vscode from 'vscode';
23

34
import { authenticatedEvent, editedEvent } from '../analytics';
45
import { AnalyticsClient } from '../analytics-node-client/src/client.min.js';
6+
import { HardcodedSite, ValidHardcodedSite } from '../config/model';
57
import { Container } from '../container';
68
import { getAgent, getAxiosInstance } from '../jira/jira-client/providers';
79
import { Logger } from '../logger';
810
import { SiteManager } from '../siteManager';
11+
import { substitute } from '../util/variable-substitution';
912
import {
1013
AccessibleResource,
1114
AuthInfo,
1215
AuthInfoState,
1316
BasicAuthInfo,
1417
DetailedSiteInfo,
18+
HardCodedAuthInfo,
1519
isBasicAuthInfo,
1620
isOAuthInfo,
1721
isPATAuthInfo,
@@ -27,6 +31,7 @@ import {
2731
} from './authInfo';
2832
import { CredentialManager } from './authStore';
2933
import { BitbucketAuthenticator } from './bitbucketAuthenticator';
34+
import { getUserForBBToken } from './getUserForBBToken';
3035
import { JiraAuthentictor as JiraAuthenticator } from './jiraAuthenticator';
3136
import { OAuthDancer } from './oauthDancer';
3237
import { basicAuthEncode } from './strategyCrypto';
@@ -113,6 +118,142 @@ export class LoginManager {
113118
}
114119
}
115120

121+
// Look for https://x-token-auth:<token>@bitbucket.org pattern
122+
private extractTokenFromGitRemoteRegex(line: string): string | null {
123+
const tokenMatch = line.match(/https:\/\/x-token-auth:([^@]+)@bitbucket\.org/);
124+
if (tokenMatch && tokenMatch[1]) {
125+
Logger.debug('Auth token found in git remote');
126+
return tokenMatch[1];
127+
}
128+
return null;
129+
}
130+
131+
/**
132+
* Extracts auth token from git remote URL
133+
* @returns The auth token or null if not found
134+
*/
135+
private async getAuthTokenFromCredentialsPath(
136+
credentialsPath: string,
137+
credentialsFormat: HardcodedSite['credentialsFormat'],
138+
): Promise<string | null> {
139+
try {
140+
const resolvedPath = substitute(credentialsPath);
141+
const credentialsContents = (await readFile(resolvedPath, 'utf-8')).trim();
142+
143+
let token: string | null = null;
144+
switch (credentialsFormat) {
145+
case 'git-remote':
146+
token = this.extractTokenFromGitRemoteRegex(credentialsContents);
147+
break;
148+
case 'self':
149+
token = credentialsContents;
150+
break;
151+
}
152+
153+
if (token) {
154+
Logger.debug(`Auth token for initial site found`);
155+
} else {
156+
Logger.warn(`No auth token found for initial site`);
157+
}
158+
return token;
159+
} catch (error) {
160+
Logger.error(error, `Error extracting auth token for initial site`);
161+
return null;
162+
}
163+
}
164+
165+
/**
166+
* This function is used to authenticate and add a hardcoded site.
167+
*
168+
* The flow is quite constant: for a given setting, simply read a token the given credentials path
169+
* and update the auth info and site based on the VS Code settings.
170+
*
171+
* The only branching happens if we provide existing auth info too. In that case, the authentication fails
172+
* if the existing auth info is the same as the fetched auth info. This flow is used while refreshing to
173+
* know if the token got refreshed or not.
174+
*/
175+
public async authenticateHardcodedSite(
176+
hardcodedSite: ValidHardcodedSite,
177+
existingAuthInfo?: AuthInfo,
178+
): Promise<boolean> {
179+
const { product, host, credentialsPath, credentialsFormat, authHeader } = hardcodedSite;
180+
181+
let siteProduct: Product | null = null;
182+
switch (product) {
183+
case 'bitbucket':
184+
siteProduct = ProductBitbucket;
185+
break;
186+
default:
187+
Logger.warn(`Invalid product for initial site`);
188+
return false;
189+
}
190+
191+
const site: SiteInfo = {
192+
host,
193+
product: siteProduct,
194+
};
195+
196+
try {
197+
const token = await this.getAuthTokenFromCredentialsPath(credentialsPath, credentialsFormat);
198+
199+
if (!token) {
200+
Logger.warn('No hardcoded Bitbucket auth token found');
201+
vscode.window.showErrorMessage('No hardcoded Bitbucket auth token found');
202+
return false;
203+
}
204+
Logger.debug('Authenticating with Bitbucket using auth token');
205+
206+
if (existingAuthInfo && existingAuthInfo.type === 'hardcoded' && existingAuthInfo.token === token) {
207+
Logger.debug(`Same token found, skipping authentication`);
208+
return false;
209+
}
210+
// The part of the code where the hardcoded site is assumed to be Bitbucket Cloud.
211+
// This function can be extended to support other sites as needed.
212+
const userData = await getUserForBBToken(LoginManager.authHeaderMaker(hardcodedSite.authHeader, token));
213+
214+
const hardcodedAuthInfo: HardCodedAuthInfo = {
215+
type: 'hardcoded',
216+
token,
217+
authHeader,
218+
user: {
219+
id: userData.id,
220+
displayName: userData.displayName,
221+
email: userData.email,
222+
avatarUrl: userData.avatarUrl,
223+
},
224+
state: AuthInfoState.Valid,
225+
};
226+
227+
const detailedSiteInfo: DetailedSiteInfo = {
228+
...site,
229+
id: site.host,
230+
name: site.host,
231+
userId: userData.id,
232+
credentialId: CredentialManager.generateCredentialId(site.product.key, userData.id),
233+
avatarUrl: userData.avatarUrl,
234+
baseLinkUrl: site.host,
235+
baseApiUrl: site.host,
236+
isCloud: hardcodedSite.isCloud ?? true,
237+
hasResolutionField: hardcodedSite.hasResolutionField ?? true,
238+
};
239+
240+
await this._credentialManager.saveAuthInfo(detailedSiteInfo, hardcodedAuthInfo);
241+
242+
this._siteManager.addOrUpdateSite(detailedSiteInfo);
243+
// Fire authenticated event
244+
authenticatedEvent(detailedSiteInfo, false).then((e) => {
245+
this._analyticsClient.sendTrackEvent(e);
246+
});
247+
Logger.info(`Successfully authenticated with Bitbucket using auth token`);
248+
249+
return true;
250+
} catch (e) {
251+
Logger.error(e, 'Error authenticating with Bitbucket token');
252+
vscode.window.showErrorMessage(`Error authenticating with Bitbucket token: ${e}`);
253+
return false;
254+
}
255+
}
256+
116257
private async getOAuthSiteDetails(
117258
product: Product,
118259
provider: OAuthProvider,
@@ -167,6 +308,8 @@ export class LoginManager {
167308
return LoginManager.authHeaderMaker('basic', basicAuthEncode(credentials.username, credentials.password));
168309
} else if (isPATAuthInfo(credentials)) {
169310
return LoginManager.authHeaderMaker('bearer', credentials.token);
311+
} else if (credentials.type === 'hardcoded') {
312+
return LoginManager.authHeaderMaker(credentials.authHeader, credentials.token);
170313
} else {
171314
return '';
172315
}

src/commands.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export enum Commands {
9797
WorkbenchOpenWorkspace = 'atlascode.workbenchOpenWorkspace',
9898
CloneRepository = 'atlascode.cloneRepository',
9999
DisableHelpExplorer = 'atlascode.disableHelpExplorer',
100+
AuthenticateWithBitbucketToken = 'atlascode.authenticateWithBitbucketToken',
100101
CreateNewJql = 'atlascode.jira.createNewJql',
101102
ToDoIssue = 'atlascode.jira.todoIssue',
102103
InProgressIssue = 'atlascode.jira.inProgressIssue',
@@ -257,8 +258,8 @@ export function registerCommands(vscodeContext: ExtensionContext) {
257258
commands.registerCommand(Commands.DisableHelpExplorer, () => {
258259
configuration.updateEffective('helpExplorerEnabled', false, null, true);
259260
}),
260-
commands.registerCommand(Commands.BitbucketOpenPullRequest, (data: { pullRequestUrl: string }) => {
261-
Container.openPullRequestHandler(data.pullRequestUrl);
261+
commands.registerCommand(Commands.AuthenticateWithBitbucketToken, () => {
262+
Container.authenticateHardcodedSite();
262263
}),
263264
);
264265
}

0 commit comments

Comments
 (0)