fix(ado): Manually pass token through http header for ado server (#543)

* support passing in token manually in auth header

* remove unneeded PAT embed check

* cleanup authheader usage

* changelog

* var name typo

* unset auth header in fetch

* move unset to finally in fetch
This commit is contained in:
Michael Sukkarieh 2025-09-27 17:14:29 -07:00 committed by GitHub
parent 7a97d4ee06
commit aa62847143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 89 additions and 40 deletions

View file

@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed
- Manually pass auth token for ado server deployments. [#543](https://github.com/sourcebot-dev/sourcebot/pull/543)
## [4.7.2] - 2025-09-22 ## [4.7.2] - 2025-09-22
### Fixed ### Fixed

View file

@ -15,6 +15,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
"repos": [ "repos": [
"organizationName/projectName/repoName", "organizationName/projectName/repoName",
"organizationName/projectName/repoName2 "organizationName/projectName/repoName2
@ -26,6 +27,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
"orgs": [ "orgs": [
"organizationName", "organizationName",
"organizationName2 "organizationName2
@ -37,6 +39,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
"projects": [ "projects": [
"organizationName/projectName", "organizationName/projectName",
"organizationName/projectName2" "organizationName/projectName2"
@ -48,6 +51,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
// Include all repos in my-org... // Include all repos in my-org...
"orgs": [ "orgs": [
"my-org" "my-org"
@ -91,6 +95,7 @@ Next, provide the access token via the `token` property, either as an environmen
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
"token": { "token": {
// note: this env var can be named anything. It // note: this env var can be named anything. It
// doesn't need to be `ADO_TOKEN`. // doesn't need to be `ADO_TOKEN`.
@ -121,6 +126,7 @@ Next, provide the access token via the `token` property, either as an environmen
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "cloud",
"token": { "token": {
"secret": "mysecret" "secret": "mysecret"
} }

View file

@ -16,7 +16,8 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"useTfsPath": true "deploymentType": "server",
"useTfsPath": true,
"repos": [ "repos": [
"organizationName/projectName/repoName", "organizationName/projectName/repoName",
"organizationName/projectName/repoName2 "organizationName/projectName/repoName2
@ -28,6 +29,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
"repos": [ "repos": [
"organizationName/projectName/repoName", "organizationName/projectName/repoName",
"organizationName/projectName/repoName2 "organizationName/projectName/repoName2
@ -39,6 +41,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
"orgs": [ "orgs": [
"collectionName", "collectionName",
"collectionName2" "collectionName2"
@ -50,6 +53,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
"projects": [ "projects": [
"collectionName/projectName", "collectionName/projectName",
"collectionName/projectName2" "collectionName/projectName2"
@ -61,6 +65,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
// Include all repos in my-org... // Include all repos in my-org...
"orgs": [ "orgs": [
"my-org" "my-org"
@ -104,6 +109,7 @@ Next, provide the access token via the `token` property, either as an environmen
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
"token": { "token": {
// note: this env var can be named anything. It // note: this env var can be named anything. It
// doesn't need to be `ADO_TOKEN`. // doesn't need to be `ADO_TOKEN`.
@ -134,6 +140,7 @@ Next, provide the access token via the `token` property, either as an environmen
```json ```json
{ {
"type": "azuredevops", "type": "azuredevops",
"deploymentType": "server",
"token": { "token": {
"secret": "mysecret" "secret": "mysecret"
} }

View file

@ -62,7 +62,6 @@
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -199,7 +198,8 @@
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
} }

View file

@ -931,7 +931,6 @@
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -1068,7 +1067,8 @@
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
}, },

View file

@ -1214,7 +1214,6 @@
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -1351,7 +1350,8 @@
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
}, },

View file

@ -7,10 +7,12 @@ type onProgressFn = (event: SimpleGitProgressEvent) => void;
export const cloneRepository = async ( export const cloneRepository = async (
{ {
cloneUrl, cloneUrl,
authHeader,
path, path,
onProgress, onProgress,
}: { }: {
cloneUrl: string, cloneUrl: string,
authHeader?: string,
path: string, path: string,
onProgress?: onProgressFn onProgress?: onProgressFn
} }
@ -24,13 +26,12 @@ export const cloneRepository = async (
path, path,
}) })
await git.clone( const cloneArgs = [
cloneUrl, "--bare",
path, ...(authHeader ? ["-c", `http.extraHeader=${authHeader}`] : [])
[ ];
"--bare",
] await git.clone(cloneUrl, path, cloneArgs);
);
await unsetGitConfig(path, ["remote.origin.url"]); await unsetGitConfig(path, ["remote.origin.url"]);
} catch (error: unknown) { } catch (error: unknown) {
@ -50,10 +51,12 @@ export const cloneRepository = async (
export const fetchRepository = async ( export const fetchRepository = async (
{ {
cloneUrl, cloneUrl,
authHeader,
path, path,
onProgress, onProgress,
}: { }: {
cloneUrl: string, cloneUrl: string,
authHeader?: string,
path: string, path: string,
onProgress?: onProgressFn onProgress?: onProgressFn
} }
@ -65,6 +68,10 @@ export const fetchRepository = async (
path: path, path: path,
}) })
if (authHeader) {
await git.addConfig("http.extraHeader", authHeader);
}
await git.fetch([ await git.fetch([
cloneUrl, cloneUrl,
"+refs/heads/*:refs/heads/*", "+refs/heads/*:refs/heads/*",
@ -81,6 +88,16 @@ export const fetchRepository = async (
} else { } else {
throw new Error(`${baseLog}. Error: ${error}`); throw new Error(`${baseLog}. Error: ${error}`);
} }
} finally {
if (authHeader) {
const git = simpleGit({
progress: onProgress,
}).cwd({
path: path,
})
await git.raw(["config", "--unset", "http.extraHeader", authHeader]);
}
} }
} }

View file

@ -175,6 +175,7 @@ export class RepoManager {
const credentials = await getAuthCredentialsForRepo(repo, this.db); const credentials = await getAuthCredentialsForRepo(repo, this.db);
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl; const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
const authHeader = credentials?.authHeader ?? undefined;
if (existsSync(repoPath) && !isReadOnly) { if (existsSync(repoPath) && !isReadOnly) {
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_ // @NOTE: in #483, we changed the cloning method s.t., we _no longer_
@ -188,6 +189,7 @@ export class RepoManager {
logger.info(`Fetching ${repo.displayName}...`); logger.info(`Fetching ${repo.displayName}...`);
const { durationMs } = await measure(() => fetchRepository({ const { durationMs } = await measure(() => fetchRepository({
cloneUrl: cloneUrlMaybeWithToken, cloneUrl: cloneUrlMaybeWithToken,
authHeader,
path: repoPath, path: repoPath,
onProgress: ({ method, stage, progress }) => { onProgress: ({ method, stage, progress }) => {
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
@ -203,6 +205,7 @@ export class RepoManager {
const { durationMs } = await measure(() => cloneRepository({ const { durationMs } = await measure(() => cloneRepository({
cloneUrl: cloneUrlMaybeWithToken, cloneUrl: cloneUrlMaybeWithToken,
authHeader,
path: repoPath, path: repoPath,
onProgress: ({ method, stage, progress }) => { onProgress: ({ method, stage, progress }) => {
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)

View file

@ -59,5 +59,6 @@ export type RepoWithConnections = Repo & { connections: (RepoToConnection & { co
export type RepoAuthCredentials = { export type RepoAuthCredentials = {
hostUrl?: string; hostUrl?: string;
token: string; token: string;
cloneUrlWithToken: string; cloneUrlWithToken?: string;
authHeader?: string;
} }

View file

@ -193,19 +193,31 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
const config = connection.config as unknown as AzureDevOpsConnectionConfig; const config = connection.config as unknown as AzureDevOpsConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
return {
hostUrl: config.url, // For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default
token, // to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token
cloneUrlWithToken: createGitCloneUrlWithToken( // appropriately in the header. To do this, we set the authHeader field here
repo.cloneUrl, if (config.deploymentType === 'server') {
{ return {
// @note: If we don't provide a username, the password will be set as the username. This seems to work hostUrl: config.url,
// for ADO cloud but not for ADO server. To fix this, we set a placeholder username to ensure the password token,
// is set correctly authHeader: "Authorization: Basic " + Buffer.from(`:${token}`).toString('base64')
username: 'user', }
password: token } else {
} return {
), hostUrl: config.url,
token,
cloneUrlWithToken: createGitCloneUrlWithToken(
repo.cloneUrl,
{
// @note: If we don't provide a username, the password will be set as the username. This seems to work
// for ADO cloud but not for ADO server. To fix this, we set a placeholder username to ensure the password
// is set correctly
username: 'user',
password: token
}
),
}
} }
} }
} }

View file

@ -61,7 +61,6 @@ const schema = {
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -198,7 +197,8 @@ const schema = {
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
} as const; } as const;

View file

@ -28,7 +28,7 @@ export interface AzureDevOpsConnectionConfig {
/** /**
* The type of Azure DevOps deployment * The type of Azure DevOps deployment
*/ */
deploymentType?: "cloud" | "server"; deploymentType: "cloud" | "server";
/** /**
* Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
*/ */

View file

@ -930,7 +930,6 @@ const schema = {
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -1067,7 +1066,8 @@ const schema = {
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
}, },

View file

@ -340,7 +340,7 @@ export interface AzureDevOpsConnectionConfig {
/** /**
* The type of Azure DevOps deployment * The type of Azure DevOps deployment
*/ */
deploymentType?: "cloud" | "server"; deploymentType: "cloud" | "server";
/** /**
* Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
*/ */

View file

@ -1213,7 +1213,6 @@ const schema = {
"cloud", "cloud",
"server" "server"
], ],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -1350,7 +1349,8 @@ const schema = {
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
}, },

View file

@ -473,7 +473,7 @@ export interface AzureDevOpsConnectionConfig {
/** /**
* The type of Azure DevOps deployment * The type of Azure DevOps deployment
*/ */
deploymentType?: "cloud" | "server"; deploymentType: "cloud" | "server";
/** /**
* Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...).
*/ */

View file

@ -30,7 +30,6 @@
"deploymentType": { "deploymentType": {
"type": "string", "type": "string",
"enum": ["cloud", "server"], "enum": ["cloud", "server"],
"default": "cloud",
"description": "The type of Azure DevOps deployment" "description": "The type of Azure DevOps deployment"
}, },
"useTfsPath": { "useTfsPath": {
@ -129,7 +128,8 @@
}, },
"required": [ "required": [
"type", "type",
"token" "token",
"deploymentType"
], ],
"additionalProperties": false "additionalProperties": false
} }