feature(azure devops): support multiple organisations
Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-azure': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Use `DefaultAzureDevOpsCredentialsProvider` to retrieve credentials for Azure DevOps.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Added `AzureDevOpsCredentialsProvider` to support multiple Azure DevOps organizations and **deprecated** `AzureIntegrationConfig.credential` and `AzureIntegrationConfig.token` in favour of `AzureIntegrationConfig.credentials`. You can now use specific credentials for different Azure DevOps (Server) organizations by specifying the `organizations` field on a credential:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- organizations:
|
||||
- my-org
|
||||
- my-other-org
|
||||
clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
- organizations:
|
||||
- yet-another-org
|
||||
personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
See the [Azure integration documentation](https://backstage.io/docs/integrations/azure/locations) for more information.
|
||||
@@ -19,10 +19,10 @@ Using a service principal:
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credential:
|
||||
clientId: ${CLIENT_ID}
|
||||
clientSecret: ${CLIENT_SECRET}
|
||||
tenantId: ${TENANT_ID}
|
||||
credentials:
|
||||
- clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
Using a managed identity:
|
||||
@@ -31,8 +31,8 @@ Using a managed identity:
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credential:
|
||||
clientId: ${CLIENT_ID}
|
||||
credentials:
|
||||
- clientId: ${AZURE_CLIENT_ID}
|
||||
```
|
||||
|
||||
Using a personal access token (PAT):
|
||||
@@ -41,24 +41,54 @@ Using a personal access token (PAT):
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
token: ${AZURE_TOKEN}
|
||||
credentials:
|
||||
- personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
You can use specific credentials for different Azure DevOps organizations by specifying the `organizations` field on the credential:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- organizations:
|
||||
- my-org
|
||||
- my-other-org
|
||||
clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
- organizations:
|
||||
- another-org
|
||||
clientId: ${AZURE_CLIENT_ID}
|
||||
- organizations:
|
||||
- yet-another-org
|
||||
personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
If you do not specify the `organizations` field the credential will be used for all organizations for which no other credential is configured.
|
||||
|
||||
> Note: An Azure DevOps provider is added automatically at startup for
|
||||
> convenience, so you only need to list it if you want to supply a
|
||||
> [token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate),
|
||||
> [personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate),
|
||||
> a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity),
|
||||
> or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity)
|
||||
|
||||
The configuration is a structure with these elements:
|
||||
|
||||
- `host`: The DevOps host; only `dev.azure.com` is supported.
|
||||
- `token` (optional): A personal access token as expected by Azure DevOps.
|
||||
- `credential`: (optional): A service principal or managed identity
|
||||
- `credentials`: (optional): A service principal, managed identity, or personal access token
|
||||
|
||||
The `credentials` element is a structure with these elements:
|
||||
|
||||
- `organizations`: (optional): A list of organizations for which this credential should be used. If not specified the credential will be used for all organizations for which no other credential is configured.
|
||||
- `clientId`: The client ID of the service principal or managed identity (required for service principal and managed identities)
|
||||
- `clientSecret`: The client secret of the service principal (required for service principal)
|
||||
- `tenantId`: The tenant ID of the service principal (required for service principal)
|
||||
- `personalAccessToken`: The personal access token (required for personal access token)
|
||||
|
||||
> Note:
|
||||
>
|
||||
> - `token` and `credential` are mutually exclusive.
|
||||
> - You cannot use a service principal or managed identity for Azure DevOps Server (on-premises) organizations
|
||||
> - You can only use a service principal or managed identity for Azure AD backed Azure DevOps organizations
|
||||
> - The token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API
|
||||
> - You can only specify one credential per host without any organizations specified
|
||||
> - The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { AwsCredentialsManager } from '@backstage/integration-aws-node';
|
||||
import { AwsS3Integration } from '@backstage/integration';
|
||||
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
|
||||
import { AzureIntegration } from '@backstage/integration';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BitbucketCloudIntegration } from '@backstage/integration';
|
||||
@@ -94,6 +95,7 @@ export class AzureUrlReader implements UrlReader {
|
||||
integration: AzureIntegration,
|
||||
deps: {
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
credentialsProvider: AzureDevOpsCredentialsProvider;
|
||||
},
|
||||
);
|
||||
// (undocumented)
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
AzureIntegration,
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
readAzureIntegrationConfig,
|
||||
ScmIntegrations,
|
||||
AzureDevOpsCredentialLike,
|
||||
AzureIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
@@ -31,12 +35,40 @@ import { getVoidLogger } from '../logging';
|
||||
import { AzureUrlReader } from './AzureUrlReader';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
|
||||
type AzureIntegrationConfigLike = Partial<
|
||||
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
|
||||
> & {
|
||||
credentials?: Partial<AzureDevOpsCredentialLike>[];
|
||||
};
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
|
||||
const urlReaderFactory = (azureIntegration: AzureIntegrationConfigLike) => {
|
||||
const credentialsProvider =
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
|
||||
ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
azure: [azureIntegration],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return new AzureUrlReader(
|
||||
new AzureIntegration(
|
||||
readAzureIntegrationConfig(new ConfigReader(azureIntegration)),
|
||||
),
|
||||
{
|
||||
treeResponseFactory,
|
||||
credentialsProvider,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('AzureUrlReader', () => {
|
||||
@@ -68,13 +100,29 @@ describe('AzureUrlReader', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const createConfig = (token?: string) =>
|
||||
new ConfigReader(
|
||||
const createConfig = (token?: string) => {
|
||||
let credentials: AzureDevOpsCredentialLike[] | undefined = undefined;
|
||||
if (token !== undefined) {
|
||||
credentials = [
|
||||
{
|
||||
personalAccessToken: token,
|
||||
},
|
||||
];
|
||||
}
|
||||
return new ConfigReader(
|
||||
{
|
||||
integrations: { azure: [{ host: 'dev.azure.com', token }] },
|
||||
integrations: {
|
||||
azure: [
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: credentials,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'test-config',
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
@@ -104,8 +152,8 @@ describe('AzureUrlReader', () => {
|
||||
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig(undefined),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
authorization: expect.anything(),
|
||||
headers: expect.objectContaining({
|
||||
authorization: expect.stringMatching(/^Bearer /),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -137,7 +185,7 @@ describe('AzureUrlReader', () => {
|
||||
url: '',
|
||||
config: createConfig(''),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
|
||||
"Invalid type in config for key 'integrations.azure[0].credentials[0].personalAccessToken' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
])('should handle error path %#', async ({ url, config, error }) => {
|
||||
await expect(async () => {
|
||||
@@ -156,16 +204,14 @@ describe('AzureUrlReader', () => {
|
||||
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
|
||||
);
|
||||
|
||||
const processor = new AzureUrlReader(
|
||||
new AzureIntegration(
|
||||
readAzureIntegrationConfig(
|
||||
new ConfigReader({
|
||||
host: 'dev.azure.com',
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
const processor = urlReaderFactory({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'my-pat',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
@@ -268,16 +314,14 @@ describe('AzureUrlReader', () => {
|
||||
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
|
||||
);
|
||||
|
||||
const processor = new AzureUrlReader(
|
||||
new AzureIntegration(
|
||||
readAzureIntegrationConfig(
|
||||
new ConfigReader({
|
||||
host: 'dev.azure.com',
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
const processor = urlReaderFactory({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'my-pat',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AzureIntegration,
|
||||
getAzureCommitsUrl,
|
||||
getAzureDownloadUrl,
|
||||
getAzureFileFetchUrl,
|
||||
getAzureRequestOptions,
|
||||
AzureDevOpsCredentialsProvider,
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
AzureIntegration,
|
||||
} from '@backstage/integration';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { Minimatch } from 'minimatch';
|
||||
@@ -47,8 +48,13 @@ import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
|
||||
export class AzureUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
const credentialProvider =
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations);
|
||||
return integrations.azure.list().map(integration => {
|
||||
const reader = new AzureUrlReader(integration, { treeResponseFactory });
|
||||
const reader = new AzureUrlReader(integration, {
|
||||
treeResponseFactory,
|
||||
credentialsProvider: credentialProvider,
|
||||
});
|
||||
const predicate = (url: URL) => url.host === integration.config.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
@@ -56,7 +62,10 @@ export class AzureUrlReader implements UrlReader {
|
||||
|
||||
constructor(
|
||||
private readonly integration: AzureIntegration,
|
||||
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
|
||||
private readonly deps: {
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
credentialsProvider: AzureDevOpsCredentialsProvider;
|
||||
},
|
||||
) {}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
@@ -72,11 +81,13 @@ export class AzureUrlReader implements UrlReader {
|
||||
const { signal } = options ?? {};
|
||||
|
||||
const builtUrl = getAzureFileFetchUrl(url);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const credentials = await this.deps.credentialsProvider.getCredentials({
|
||||
url: builtUrl,
|
||||
});
|
||||
response = await fetch(builtUrl, {
|
||||
...(await getAzureRequestOptions(this.integration.config)),
|
||||
headers: credentials?.headers,
|
||||
// TODO(freben): The signal cast is there because pre-3.x versions of
|
||||
// node-fetch have a very slightly deviating AbortSignal type signature.
|
||||
// The difference does not affect us in practice however. The cast can
|
||||
@@ -111,10 +122,13 @@ export class AzureUrlReader implements UrlReader {
|
||||
|
||||
// Get latest commit SHA
|
||||
|
||||
const commitsAzureResponse = await fetch(
|
||||
getAzureCommitsUrl(url),
|
||||
await getAzureRequestOptions(this.integration.config),
|
||||
);
|
||||
const credentials = await this.deps.credentialsProvider.getCredentials({
|
||||
url: url,
|
||||
});
|
||||
|
||||
const commitsAzureResponse = await fetch(getAzureCommitsUrl(url), {
|
||||
headers: credentials?.headers,
|
||||
});
|
||||
if (!commitsAzureResponse.ok) {
|
||||
const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`;
|
||||
if (commitsAzureResponse.status === 404) {
|
||||
@@ -129,9 +143,10 @@ export class AzureUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), {
|
||||
...(await getAzureRequestOptions(this.integration.config, {
|
||||
headers: {
|
||||
...credentials?.headers,
|
||||
Accept: 'application/zip',
|
||||
})),
|
||||
},
|
||||
// TODO(freben): The signal cast is there because pre-3.x versions of
|
||||
// node-fetch have a very slightly deviating AbortSignal type signature.
|
||||
// The difference does not affect us in practice however. The cast can be
|
||||
@@ -198,7 +213,9 @@ export class AzureUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
toString() {
|
||||
const { host, token } = this.integration.config;
|
||||
return `azure{host=${host},authed=${Boolean(token)}}`;
|
||||
const { host, credentials } = this.integration.config;
|
||||
return `azure{host=${host},authed=${Boolean(
|
||||
credentials !== undefined && credentials.length > 0,
|
||||
)}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +39,58 @@ export type AwsS3IntegrationConfig = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureClientSecretCredential = {
|
||||
export type AzureClientSecretCredential = AzureCredentialBase & {
|
||||
kind: 'ClientSecret';
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureCredential =
|
||||
export type AzureCredentialBase = {
|
||||
kind: AzureDevOpsCredentialKind;
|
||||
organizations?: string[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredential =
|
||||
| AzureClientSecretCredential
|
||||
| AzureManagedIdentityCredential;
|
||||
| AzureManagedIdentityCredential
|
||||
| PersonalAccessTokenCredential;
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredentialKind =
|
||||
| 'PersonalAccessToken'
|
||||
| 'ClientSecret'
|
||||
| 'ManagedIdentity';
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredentialLike = Omit<
|
||||
Partial<AzureClientSecretCredential> &
|
||||
Partial<AzureManagedIdentityCredential> &
|
||||
Partial<PersonalAccessTokenCredential>,
|
||||
'kind'
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredentials = {
|
||||
headers: {
|
||||
[name: string]: string;
|
||||
};
|
||||
token: string;
|
||||
type: AzureDevOpsCredentialType;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface AzureDevOpsCredentialsProvider {
|
||||
// (undocumented)
|
||||
getCredentials(opts: {
|
||||
url: string;
|
||||
}): Promise<AzureDevOpsCredentials | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredentialType = 'bearer' | 'pat';
|
||||
|
||||
// @public
|
||||
export class AzureIntegration implements ScmIntegration {
|
||||
@@ -75,11 +117,13 @@ export class AzureIntegration implements ScmIntegration {
|
||||
export type AzureIntegrationConfig = {
|
||||
host: string;
|
||||
token?: string;
|
||||
credential?: AzureCredential;
|
||||
credential?: AzureDevOpsCredential;
|
||||
credentials?: AzureDevOpsCredential[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureManagedIdentityCredential = {
|
||||
export type AzureManagedIdentityCredential = AzureCredentialBase & {
|
||||
kind: 'ManagedIdentity';
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
@@ -180,6 +224,20 @@ export function buildGerritGitilesArchiveUrl(
|
||||
filePath: string,
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export class DefaultAzureDevOpsCredentialsProvider
|
||||
implements AzureDevOpsCredentialsProvider
|
||||
{
|
||||
// (undocumented)
|
||||
static fromIntegrations(
|
||||
integrations: ScmIntegrationRegistry,
|
||||
): DefaultAzureDevOpsCredentialsProvider;
|
||||
// (undocumented)
|
||||
getCredentials(opts: {
|
||||
url: string;
|
||||
}): Promise<AzureDevOpsCredentials | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class DefaultGithubCredentialsProvider
|
||||
implements GithubCredentialsProvider
|
||||
@@ -250,7 +308,7 @@ export function getAzureDownloadUrl(url: string): string;
|
||||
// @public
|
||||
export function getAzureFileFetchUrl(url: string): string;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export function getAzureRequestOptions(
|
||||
config: AzureIntegrationConfig,
|
||||
additionalHeaders?: Record<string, string>,
|
||||
@@ -600,6 +658,12 @@ export function parseGerritGitilesUrl(
|
||||
// @public
|
||||
export function parseGerritJsonResponse(response: Response): Promise<unknown>;
|
||||
|
||||
// @public
|
||||
export type PersonalAccessTokenCredential = AzureCredentialBase & {
|
||||
kind: 'PersonalAccessToken';
|
||||
personalAccessToken: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function readAwsS3IntegrationConfig(
|
||||
config: Config,
|
||||
|
||||
Vendored
+30
@@ -30,8 +30,38 @@ export interface Config {
|
||||
/**
|
||||
* Token used to authenticate requests.
|
||||
* @visibility secret
|
||||
* @deprecated Use `credentials` instead.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The credential to use for requests.
|
||||
*
|
||||
* If no credential is specified anonymous access is used.
|
||||
*
|
||||
* @visibility secret
|
||||
* @deprecated Use `credentials` instead.
|
||||
*/
|
||||
credential?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tenantId?: string;
|
||||
personalAccessToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
|
||||
* If not organization matches the first credential without an organization is used.
|
||||
*
|
||||
* If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used.
|
||||
* @visibility secret
|
||||
*/
|
||||
credentials?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tenantId?: string;
|
||||
personalAccessToken?: string;
|
||||
}[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ export class AzureIntegration implements ScmIntegration {
|
||||
const configs = readAzureIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [],
|
||||
);
|
||||
|
||||
return basicIntegrations(
|
||||
configs.map(c => new AzureIntegration(c)),
|
||||
i => i.config.host,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
import {
|
||||
AccessToken,
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
|
||||
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
|
||||
typeof ClientSecretCredential
|
||||
>;
|
||||
|
||||
const MockedManagedIdentityCredential =
|
||||
ManagedIdentityCredential as jest.MockedClass<
|
||||
typeof ManagedIdentityCredential
|
||||
>;
|
||||
|
||||
jest.mock('@azure/identity');
|
||||
|
||||
const seconds = (s: number) => s * 1000;
|
||||
const minutes = (m: number) => seconds(60) * m;
|
||||
const hours = (h: number) => minutes(60) * h;
|
||||
|
||||
describe('CachedAzureDevOpsCredentialsProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
token: 'fake-client-secret-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
token: 'fake-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should return a pat credential when a personal access token is configured', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'PersonalAccessToken',
|
||||
personalAccessToken: 'token',
|
||||
});
|
||||
const { headers, type, token } = await manager.getCredentials();
|
||||
|
||||
expect(headers).toStrictEqual({
|
||||
Authorization: `Basic ${btoa(`:${token}`)}`,
|
||||
});
|
||||
|
||||
expect(type).toBe('pat');
|
||||
expect(token).toBe('token');
|
||||
});
|
||||
|
||||
it('Should return a bearer credential when a client secret is configured', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
const { headers, type, token } = await manager.getCredentials();
|
||||
|
||||
expect(headers).toStrictEqual({
|
||||
Authorization: `Bearer fake-client-secret-token`,
|
||||
});
|
||||
|
||||
expect(type).toBe('bearer');
|
||||
expect(token).toBe('fake-client-secret-token');
|
||||
});
|
||||
|
||||
it('Should return a bearer credential when a managed identity is configured', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'id',
|
||||
});
|
||||
|
||||
const { headers, type, token } = await manager.getCredentials();
|
||||
|
||||
expect(headers).toStrictEqual({
|
||||
Authorization: `Bearer fake-managed-identity-token`,
|
||||
});
|
||||
|
||||
expect(type).toBe('bearer');
|
||||
expect(token).toBe('fake-managed-identity-token');
|
||||
});
|
||||
|
||||
it('Should not refresh the client secret token when it does not expire within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedClientSecretCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should return the managed identity token when it does not expire within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'id',
|
||||
});
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should refresh the client secret token when it expires within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + minutes(9) + seconds(59),
|
||||
token: 'fake-client-secret-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedClientSecretCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should refresh the managed identity token when it expires within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'id',
|
||||
});
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + minutes(9) + seconds(59),
|
||||
token: 'fake-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AzureDevOpsCredential, PersonalAccessTokenCredential } from './config';
|
||||
import {
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
TokenCredential,
|
||||
} from '@azure/identity';
|
||||
import {
|
||||
AzureDevOpsCredentials,
|
||||
AzureDevOpsCredentialsProvider,
|
||||
} from './types';
|
||||
|
||||
type CachedAzureDevOpsCredentials = AzureDevOpsCredentials & {
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
function exhaustiveCheck(_param: never) {}
|
||||
|
||||
const tenMinutes = 1000 * 60 * 10;
|
||||
|
||||
/**
|
||||
* A credentials provider that caches the credentials for as long as it is valid.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CachedAzureDevOpsCredentialsProvider
|
||||
implements AzureDevOpsCredentialsProvider
|
||||
{
|
||||
azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
|
||||
cached: CachedAzureDevOpsCredentials | undefined;
|
||||
|
||||
static fromAzureDevOpsCredential(
|
||||
credential: AzureDevOpsCredential,
|
||||
): CachedAzureDevOpsCredentialsProvider {
|
||||
switch (credential.kind) {
|
||||
case 'PersonalAccessToken':
|
||||
return CachedAzureDevOpsCredentialsProvider.fromPersonalAccessTokenCredential(
|
||||
credential,
|
||||
);
|
||||
case 'ClientSecret':
|
||||
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
|
||||
new ClientSecretCredential(
|
||||
credential.tenantId,
|
||||
credential.clientId,
|
||||
credential.clientSecret,
|
||||
),
|
||||
);
|
||||
case 'ManagedIdentity':
|
||||
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
|
||||
new ManagedIdentityCredential(credential.clientId),
|
||||
);
|
||||
default:
|
||||
exhaustiveCheck(credential);
|
||||
|
||||
throw new Error(
|
||||
`Credential kind '${(credential as any).kind}' not supported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static fromTokenCredential(
|
||||
credential: TokenCredential,
|
||||
): CachedAzureDevOpsCredentialsProvider {
|
||||
return new CachedAzureDevOpsCredentialsProvider(credential);
|
||||
}
|
||||
|
||||
static fromPersonalAccessTokenCredential(
|
||||
credential: PersonalAccessTokenCredential,
|
||||
) {
|
||||
return new CachedAzureDevOpsCredentialsProvider(
|
||||
credential.personalAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(private readonly credential: TokenCredential | string) {}
|
||||
|
||||
async getCredentials(): Promise<AzureDevOpsCredentials> {
|
||||
if (
|
||||
this.cached === undefined ||
|
||||
(this.cached.expiresAt !== undefined &&
|
||||
Date.now() > this.cached.expiresAt)
|
||||
) {
|
||||
if (typeof this.credential === 'string') {
|
||||
this.cached = {
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`:${this.credential}`)}`,
|
||||
},
|
||||
type: 'pat',
|
||||
token: this.credential,
|
||||
};
|
||||
} else {
|
||||
const accessToken = await this.credential.getToken(
|
||||
this.azureDevOpsScope,
|
||||
);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to retrieve access token');
|
||||
}
|
||||
|
||||
this.cached = {
|
||||
expiresAt: accessToken.expiresOnTimestamp - tenMinutes,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken.token}`,
|
||||
},
|
||||
type: 'bearer',
|
||||
token: accessToken.token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.cached;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ScmIntegrations } from '../ScmIntegrations';
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
import { AzureDevOpsCredentialLike, AzureIntegrationConfig } from './config';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DefaultAzureDevOpsCredentialsProvider } from './DefaultAzureDevOpsCredentialsProvider';
|
||||
import {
|
||||
AccessToken,
|
||||
ClientSecretCredential,
|
||||
DefaultAzureCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
type AzureIntegrationConfigLike = Partial<
|
||||
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
|
||||
> & {
|
||||
credential?: Partial<AzureDevOpsCredentialLike>;
|
||||
credentials?: Partial<AzureDevOpsCredentialLike>[];
|
||||
};
|
||||
|
||||
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
|
||||
typeof ClientSecretCredential
|
||||
>;
|
||||
|
||||
const MockedManagedIdentityCredential =
|
||||
ManagedIdentityCredential as jest.MockedClass<
|
||||
typeof ManagedIdentityCredential
|
||||
>;
|
||||
|
||||
const MockedDefaultAzureCredential = DefaultAzureCredential as jest.MockedClass<
|
||||
typeof DefaultAzureCredential
|
||||
>;
|
||||
|
||||
jest.mock('@azure/identity');
|
||||
|
||||
describe('DefaultAzureDevOpsCredentialProvider', () => {
|
||||
const buildProvider = (azureIntegrations: AzureIntegrationConfigLike[]) =>
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
|
||||
ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
azure: azureIntegrations,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
|
||||
token: 'fake-client-secret-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
|
||||
token: 'fake-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
MockedDefaultAzureCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
|
||||
token: 'fake-default-azure-credential-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
jest.spyOn(
|
||||
CachedAzureDevOpsCredentialsProvider,
|
||||
'fromAzureDevOpsCredential',
|
||||
);
|
||||
|
||||
jest.spyOn(CachedAzureDevOpsCredentialsProvider, 'fromTokenCredential');
|
||||
|
||||
jest.spyOn(
|
||||
CachedAzureDevOpsCredentialsProvider,
|
||||
'fromPersonalAccessTokenCredential',
|
||||
);
|
||||
});
|
||||
|
||||
describe('fromIntegrations', () => {
|
||||
it('Should create a credential provider when a credential is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should create a single credential provider per credential', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'client-id-1',
|
||||
},
|
||||
{
|
||||
organizations: ['org3', 'org4'],
|
||||
clientId: 'client-id-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should create a default credential provider for Azure DevOps when no credential is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledWith(new DefaultAzureCredential());
|
||||
});
|
||||
|
||||
it('Should create a default credential provider for Azure DevOps when no default credential is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'client-id',
|
||||
tenantId: 'tenant-id',
|
||||
clientSecret: 'client-secret',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledWith(new DefaultAzureCredential());
|
||||
});
|
||||
|
||||
it('Should not create a default credential provider for Azure DevOps when another default credential is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'client-id',
|
||||
tenantId: 'tenant-id',
|
||||
clientSecret: 'client-secret',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledWith(expect.any(ClientSecretCredential));
|
||||
});
|
||||
|
||||
it('Should not create a default credential provider for on-premise Azure DevOps server when no credential is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
|
||||
).toHaveBeenCalledTimes(0);
|
||||
|
||||
// expect 1 call because the Azure integration adds a default integration for dev.azure.com when it is not configured
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
CachedAzureDevOpsCredentialsProvider.fromPersonalAccessTokenCredential,
|
||||
).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentials', () => {
|
||||
describe('Azure DevOps (dev.azure.com)', () => {
|
||||
it('Should return a token when a credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = provider.getCredentials({
|
||||
url: 'https://dev.azure.com/org1/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
|
||||
it('Should return DefaultAzureCredential when no credential with the same organization is specified and host is dev.azure.com', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: 'https://dev.azure.com/org2/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'fake-default-azure-credential-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should return undefined when no credential is specified and host is Azure DevOps server', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: 'https://my.devops.server/org2/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should prefer organization credential when a credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'fallback-pat',
|
||||
},
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'org1-pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: 'https://dev.azure.com/org1/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'org1-pat',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should fallback to host credential when no credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'fallback-pat',
|
||||
},
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'org1-pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: 'https://dev.azure.com/org2/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'fallback-pat',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Azure DevOps Server', () => {
|
||||
[
|
||||
'https://{host}/{organization}/{project}',
|
||||
'https://{host}/tfs/{organization}/{project}',
|
||||
].map(format => {
|
||||
describe(`With url format ${format}`, () => {
|
||||
const formatUrl = (opts: {
|
||||
host: string;
|
||||
organization: string;
|
||||
project: string;
|
||||
}) =>
|
||||
format
|
||||
.replace('{host}', opts.host)
|
||||
.replace('{organization}', opts.organization)
|
||||
.replace('{project}', opts.project);
|
||||
|
||||
it('Should return a token when a credential with the same host is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: formatUrl({
|
||||
host: 'my.devops.server',
|
||||
organization: 'org1',
|
||||
project: 'project1',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
|
||||
it('Should prefer organization credential when a credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'fallback-pat',
|
||||
},
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'org1-pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: formatUrl({
|
||||
host: 'my.devops.server',
|
||||
organization: 'org1',
|
||||
project: 'project1',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'org1-pat',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should fallback to host credential when no credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'fallback-pat',
|
||||
},
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 'org1-pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: formatUrl({
|
||||
host: 'my.devops.server',
|
||||
organization: 'org2',
|
||||
project: 'project1',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(credentials).toMatchObject({
|
||||
token: 'fallback-pat',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should return a undefined when no credential with the same host is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'my.devops.server',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = await provider.getCredentials({
|
||||
url: formatUrl({
|
||||
host: 'my.other.devops.server',
|
||||
organization: 'org1',
|
||||
project: 'project1',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(credentials).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
AzureDevOpsCredentials,
|
||||
AzureDevOpsCredentialsProvider,
|
||||
} from './types';
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
import { ScmIntegrationRegistry } from '../registry';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
|
||||
/**
|
||||
* Default implementation of AzureDevOpsCredentialsProvider.
|
||||
* @public
|
||||
*/
|
||||
export class DefaultAzureDevOpsCredentialsProvider
|
||||
implements AzureDevOpsCredentialsProvider
|
||||
{
|
||||
static fromIntegrations(
|
||||
integrations: ScmIntegrationRegistry,
|
||||
): DefaultAzureDevOpsCredentialsProvider {
|
||||
const providers = integrations.azure.list().reduce((acc, integration) => {
|
||||
integration.config.credentials?.forEach(credential => {
|
||||
if (
|
||||
credential.organizations === undefined ||
|
||||
credential.organizations.length === 0
|
||||
) {
|
||||
if (acc.get(integration.config.host) === undefined) {
|
||||
acc.set(
|
||||
integration.config.host,
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
|
||||
credential,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const provider =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
|
||||
credential,
|
||||
);
|
||||
credential.organizations?.forEach(organization => {
|
||||
acc.set(`${integration.config.host}/${organization}`, provider);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
integration.config.host === 'dev.azure.com' &&
|
||||
acc.get(integration.config.host) === undefined
|
||||
) {
|
||||
acc.set(
|
||||
integration.config.host,
|
||||
CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
|
||||
new DefaultAzureCredential(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Map<string, CachedAzureDevOpsCredentialsProvider>());
|
||||
|
||||
return new DefaultAzureDevOpsCredentialsProvider(providers);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly providers: Map<
|
||||
string,
|
||||
CachedAzureDevOpsCredentialsProvider
|
||||
>,
|
||||
) {}
|
||||
|
||||
private forAzureDevOpsServerOrganization(
|
||||
url: URL,
|
||||
): AzureDevOpsCredentialsProvider | undefined {
|
||||
const parts = url.pathname.split('/').filter(part => part !== '');
|
||||
if (url.host !== 'dev.azure.com' && parts.length > 0) {
|
||||
if (parts[0] !== 'tfs') {
|
||||
// url format: https://{host}/{organization}
|
||||
return this.providers.get(`${url.host}/${parts[0]}`);
|
||||
} else if (parts[0] === 'tfs' && parts.length > 1) {
|
||||
// url format: https://{host}/tfs/{organization}
|
||||
return this.providers.get(`${url.host}/${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private forAzureDevOpsOrganization(
|
||||
url: URL,
|
||||
): AzureDevOpsCredentialsProvider | undefined {
|
||||
const parts = url.pathname.split('/').filter(part => part !== '');
|
||||
if (url.host === 'dev.azure.com' && parts.length > 0) {
|
||||
// url format: https://{host}/{organization}
|
||||
return this.providers.get(`${url.host}/${parts[0]}`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private forHost(url: URL): AzureDevOpsCredentialsProvider | undefined {
|
||||
return this.providers.get(url.host);
|
||||
}
|
||||
|
||||
async getCredentials(opts: {
|
||||
url: string;
|
||||
}): Promise<AzureDevOpsCredentials | undefined> {
|
||||
const url = new URL(opts.url);
|
||||
const provider =
|
||||
this.forAzureDevOpsOrganization(url) ??
|
||||
this.forAzureDevOpsServerOrganization(url) ??
|
||||
this.forHost(url);
|
||||
|
||||
if (provider === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return provider.getCredentials(opts);
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,30 @@
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { loadConfigSchema } from '@backstage/config-loader';
|
||||
import {
|
||||
AzureDevOpsCredentialLike,
|
||||
AzureIntegrationConfig,
|
||||
readAzureIntegrationConfig,
|
||||
readAzureIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
type AzureIntegrationConfigLike = Partial<
|
||||
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
|
||||
> & {
|
||||
credential?: Partial<AzureDevOpsCredentialLike>;
|
||||
credentials?: Partial<AzureDevOpsCredentialLike>[];
|
||||
};
|
||||
|
||||
describe('readAzureIntegrationConfig', () => {
|
||||
function buildConfig(data: Partial<AzureIntegrationConfig>): Config {
|
||||
const valid: any = {
|
||||
host: 'dev.azure.com',
|
||||
};
|
||||
|
||||
function buildConfig(data: AzureIntegrationConfigLike): Config {
|
||||
return new ConfigReader(data);
|
||||
}
|
||||
|
||||
async function buildFrontendConfig(
|
||||
data: Partial<AzureIntegrationConfig>,
|
||||
data: AzureIntegrationConfigLike,
|
||||
): Promise<Config> {
|
||||
const fullSchema = await loadConfigSchema({
|
||||
dependencies: ['@backstage/integration'],
|
||||
@@ -51,39 +63,155 @@ describe('readAzureIntegrationConfig', () => {
|
||||
return new ConfigReader((processed[0].data as any).integrations.azure[0]);
|
||||
}
|
||||
|
||||
it('reads all values when using a token', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a credential', () => {
|
||||
it('reads all values when using a personal access token credential', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a personal access token credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a client secret credential', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a client secret credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,96 +220,296 @@ describe('readAzureIntegrationConfig', () => {
|
||||
expect(output).toEqual({ host: 'dev.azure.com' });
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'dev.azure.com',
|
||||
};
|
||||
it('maps deprecated token to credentials', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
token: 't',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps deprecated credential to credentials', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenantId',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenantId',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects config when host is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, host: 7 })),
|
||||
).toThrow(/host/);
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, token: 7 })),
|
||||
).toThrow(/token/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, credential: 7 })),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({ ...valid, credential: { clientId: 7 } }),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({ ...valid, credential: { clientSecret: 7 } }),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({ ...valid, credential: { tenantId: 7 } }),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, credential: {} })),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({ ...valid, credential: { clientSecret: 'secret' } }),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({ ...valid, credential: { tenantId: 'tenant' } }),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
it('rejects config when organizations is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credential: { clientId: 'id', clientSecret: 'secret' },
|
||||
credentials: [
|
||||
{
|
||||
organizations: [1, 2, 'org'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
).toThrow(/credentials/);
|
||||
});
|
||||
|
||||
it('rejects config when token is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credential: { clientId: 'id', tenantId: 'tenant' },
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 7,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
).toThrow(/credentials/);
|
||||
});
|
||||
|
||||
it('rejects config when clientId is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
clientId: 7,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credentials/);
|
||||
});
|
||||
|
||||
it('rejects config when clientSecret is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 7,
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credentials/);
|
||||
});
|
||||
|
||||
it('rejects config when tenantId is not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 7,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credentials/);
|
||||
});
|
||||
|
||||
it('rejects config when at least one credential not valid', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'token',
|
||||
},
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
{
|
||||
clientId: 'id',
|
||||
personalAccessToken: 'token',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/not a valid credential/);
|
||||
});
|
||||
|
||||
it('rejects config when using a client secret credential for Azure DevOps server', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
host: 'a.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/hosts/);
|
||||
});
|
||||
|
||||
it('rejects config when using a managed identity for Azure DevOps server', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
host: 'a.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/personal access tokens/);
|
||||
});
|
||||
|
||||
it('rejects config when using organizations for Azure DevOps server', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
host: 'a.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/hosts/);
|
||||
});
|
||||
|
||||
it('rejects config when both the credential and credentials field are specified', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
tenantId: 'tenant',
|
||||
clientSecret: 'secret',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
});
|
||||
|
||||
it('rejects config when both the token and credentials field are specified', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
token: 'token',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
|
||||
it('rejects config when more than one credential does not specify an organization', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
{
|
||||
organizations: [],
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/organizations/);
|
||||
});
|
||||
|
||||
it('rejects config when multiple credentials specify the same organization', () => {
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
{
|
||||
organizations: ['org2', 'org3'],
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/organization org2/);
|
||||
});
|
||||
|
||||
it('works on the frontend', async () => {
|
||||
expect(
|
||||
readAzureIntegrationConfig(
|
||||
await buildFrontendConfig({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -191,46 +519,184 @@ describe('readAzureIntegrationConfig', () => {
|
||||
});
|
||||
|
||||
describe('readAzureIntegrationConfigs', () => {
|
||||
function buildConfig(data: Partial<AzureIntegrationConfig>[]): Config[] {
|
||||
function buildConfig(data: AzureIntegrationConfigLike[]): Config[] {
|
||||
return data.map(item => new ConfigReader(item));
|
||||
}
|
||||
|
||||
it('reads all values when using a token', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a credential', () => {
|
||||
it('reads all values when using a personal access token credential', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
organizations: ['org1'],
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads all values when using a personal access token credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
personalAccessToken: 't',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads all values when using a client secret credential', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads all values when using a client secret credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a default entry when missing', () => {
|
||||
|
||||
@@ -36,6 +36,8 @@ export type AzureIntegrationConfig = {
|
||||
* The authorization token to use for requests.
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*
|
||||
* @deprecated Use `credentials` instead.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
@@ -43,15 +45,50 @@ export type AzureIntegrationConfig = {
|
||||
* The credential to use for requests.
|
||||
*
|
||||
* If no credential is specified anonymous access is used.
|
||||
*
|
||||
* @deprecated Use `credentials` instead.
|
||||
*/
|
||||
credential?: AzureCredential;
|
||||
credential?: AzureDevOpsCredential;
|
||||
|
||||
/**
|
||||
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
|
||||
* If not organization matches the first credential without an organization is used.
|
||||
*
|
||||
* If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used.
|
||||
*/
|
||||
credentials?: AzureDevOpsCredential[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate using a client secret that was generated for an App Registration.
|
||||
* The kind of Azure DevOps credential.
|
||||
* @public
|
||||
*/
|
||||
export type AzureClientSecretCredential = {
|
||||
export type AzureDevOpsCredentialKind =
|
||||
| 'PersonalAccessToken'
|
||||
| 'ClientSecret'
|
||||
| 'ManagedIdentity';
|
||||
|
||||
/**
|
||||
* Common fields for the Azure DevOps credentials.
|
||||
* @public
|
||||
*/
|
||||
export type AzureCredentialBase = {
|
||||
/**
|
||||
* The kind of credential.
|
||||
*/
|
||||
kind: AzureDevOpsCredentialKind;
|
||||
/**
|
||||
* The Azure DevOps organizations for which to use this credential.
|
||||
*/
|
||||
organizations?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A client secret credential that was generated for an App Registration.
|
||||
* @public
|
||||
*/
|
||||
export type AzureClientSecretCredential = AzureCredentialBase & {
|
||||
kind: 'ClientSecret';
|
||||
/**
|
||||
* The Azure Active Directory tenant
|
||||
*/
|
||||
@@ -68,10 +105,11 @@ export type AzureClientSecretCredential = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate using a managed identity available at the deployment environment.
|
||||
* A managed identity credential.
|
||||
* @public
|
||||
*/
|
||||
export type AzureManagedIdentityCredential = {
|
||||
export type AzureManagedIdentityCredential = AzureCredentialBase & {
|
||||
kind: 'ManagedIdentity';
|
||||
/**
|
||||
* The clientId
|
||||
*/
|
||||
@@ -79,33 +117,77 @@ export type AzureManagedIdentityCredential = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Credential used to authenticate to Azure Active Directory.
|
||||
* A personal access token credential.
|
||||
* @public
|
||||
*/
|
||||
export type AzureCredential =
|
||||
export type PersonalAccessTokenCredential = AzureCredentialBase & {
|
||||
kind: 'PersonalAccessToken';
|
||||
personalAccessToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The general shape of a credential that can be used to authenticate to Azure DevOps.
|
||||
* @public
|
||||
*/
|
||||
export type AzureDevOpsCredentialLike = Omit<
|
||||
Partial<AzureClientSecretCredential> &
|
||||
Partial<AzureManagedIdentityCredential> &
|
||||
Partial<PersonalAccessTokenCredential>,
|
||||
'kind'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Credential used to authenticate to Azure DevOps.
|
||||
* @public
|
||||
*/
|
||||
export type AzureDevOpsCredential =
|
||||
| AzureClientSecretCredential
|
||||
| AzureManagedIdentityCredential;
|
||||
export const isAzureClientSecretCredential = (
|
||||
credential: Partial<AzureCredential>,
|
||||
): credential is AzureClientSecretCredential => {
|
||||
const clientSecretCredential = credential as AzureClientSecretCredential;
|
||||
| AzureManagedIdentityCredential
|
||||
| PersonalAccessTokenCredential;
|
||||
|
||||
return (
|
||||
Object.keys(credential).length === 3 &&
|
||||
clientSecretCredential.clientId !== undefined &&
|
||||
clientSecretCredential.clientSecret !== undefined &&
|
||||
clientSecretCredential.tenantId !== undefined
|
||||
);
|
||||
};
|
||||
const AzureDevOpsCredentialFields = [
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'tenantId',
|
||||
'personalAccessToken',
|
||||
] as const;
|
||||
type AzureDevOpsCredentialField = (typeof AzureDevOpsCredentialFields)[number];
|
||||
|
||||
export const isAzureManagedIdentityCredential = (
|
||||
credential: Partial<AzureCredential>,
|
||||
): credential is AzureManagedIdentityCredential => {
|
||||
return (
|
||||
Object.keys(credential).length === 1 &&
|
||||
(credential as AzureManagedIdentityCredential).clientId !== undefined
|
||||
);
|
||||
};
|
||||
const AzureDevopsCredentialFieldMap = new Map<
|
||||
AzureDevOpsCredentialKind,
|
||||
AzureDevOpsCredentialField[]
|
||||
>([
|
||||
['ClientSecret', ['clientId', 'clientSecret', 'tenantId']],
|
||||
['ManagedIdentity', ['clientId']],
|
||||
['PersonalAccessToken', ['personalAccessToken']],
|
||||
]);
|
||||
|
||||
function asAzureDevOpsCredential(
|
||||
credential: AzureDevOpsCredentialLike,
|
||||
): AzureDevOpsCredential {
|
||||
for (const entry of AzureDevopsCredentialFieldMap.entries()) {
|
||||
const [kind, requiredFields] = entry;
|
||||
|
||||
const forbiddenFields = AzureDevOpsCredentialFields.filter(
|
||||
field => !requiredFields.includes(field as AzureDevOpsCredentialField),
|
||||
);
|
||||
|
||||
if (
|
||||
requiredFields.every(field => credential[field] !== undefined) &&
|
||||
forbiddenFields.every(field => credential[field] === undefined)
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
organizations: credential.organizations,
|
||||
...requiredFields.reduce((acc, field) => {
|
||||
acc[field] = credential[field];
|
||||
return acc;
|
||||
}, {} as Record<string, any>),
|
||||
} as AzureDevOpsCredential;
|
||||
}
|
||||
}
|
||||
throw new Error('is not a valid credential');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single Azure integration config.
|
||||
@@ -117,15 +199,62 @@ export function readAzureIntegrationConfig(
|
||||
config: Config,
|
||||
): AzureIntegrationConfig {
|
||||
const host = config.getOptionalString('host') ?? AZURE_HOST;
|
||||
|
||||
let credentialConfigs = config
|
||||
.getOptionalConfigArray('credentials')
|
||||
?.map(credential => {
|
||||
const result: Partial<AzureDevOpsCredentialLike> = {
|
||||
organizations: credential.getOptionalStringArray('organizations'),
|
||||
personalAccessToken: credential.getOptionalString(
|
||||
'personalAccessToken',
|
||||
),
|
||||
tenantId: credential.getOptionalString('tenantId'),
|
||||
clientId: credential.getOptionalString('clientId'),
|
||||
clientSecret: credential.getOptionalString('clientSecret'),
|
||||
};
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const token = config.getOptionalString('token');
|
||||
|
||||
const credential = config.getOptional<AzureCredential>('credential')
|
||||
? {
|
||||
if (
|
||||
config.getOptional('credential') !== undefined &&
|
||||
config.getOptional('credentials') !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, 'credential' and 'credentials' cannot be used together. Use 'credentials' instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.getOptional('token') !== undefined &&
|
||||
config.getOptional('credentials') !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, 'token' and 'credentials' cannot be used together. Use 'credentials' instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (token !== undefined) {
|
||||
const mapped = [{ personalAccessToken: token }];
|
||||
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
|
||||
}
|
||||
|
||||
if (config.getOptional('credential') !== undefined) {
|
||||
const mapped = [
|
||||
{
|
||||
organizations: config.getOptionalStringArray(
|
||||
'credential.organizations',
|
||||
),
|
||||
token: config.getOptionalString('credential.token'),
|
||||
tenantId: config.getOptionalString('credential.tenantId'),
|
||||
clientId: config.getOptionalString('credential.clientId'),
|
||||
clientSecret: config.getOptionalString('credential.clientSecret'),
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
];
|
||||
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
|
||||
}
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
throw new Error(
|
||||
@@ -133,29 +262,92 @@ export function readAzureIntegrationConfig(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
credential &&
|
||||
!isAzureClientSecretCredential(credential) &&
|
||||
!isAzureManagedIdentityCredential(credential)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, credential is not valid`,
|
||||
let credentials: AzureDevOpsCredential[] | undefined = undefined;
|
||||
if (credentialConfigs !== undefined) {
|
||||
const errors = credentialConfigs
|
||||
?.reduce((acc, credentialConfig, index) => {
|
||||
let error: string | undefined = undefined;
|
||||
try {
|
||||
asAzureDevOpsCredential(credentialConfig);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
if (error !== undefined) {
|
||||
acc.push(`credential at position ${index + 1} ${error}`);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, Array.of<string>())
|
||||
.concat(
|
||||
Object.entries(
|
||||
credentialConfigs
|
||||
.filter(
|
||||
credential =>
|
||||
credential.organizations !== undefined &&
|
||||
credential.organizations.length > 0,
|
||||
)
|
||||
.reduce((acc, credential, index) => {
|
||||
credential.organizations?.forEach(organization => {
|
||||
if (!acc[organization]) {
|
||||
acc[organization] = [];
|
||||
}
|
||||
|
||||
acc[organization].push(index + 1);
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, number[]>),
|
||||
)
|
||||
.filter(([_, indexes]) => indexes.length > 1)
|
||||
.reduce((acc, [org, indexes]) => {
|
||||
acc.push(
|
||||
`organization ${org} is specified multiple times in credentials at positions ${indexes
|
||||
.slice(0, indexes.length - 1)
|
||||
.join(', ')} and ${indexes[indexes.length - 1]}`,
|
||||
);
|
||||
return acc;
|
||||
}, Array.of<string>()),
|
||||
);
|
||||
|
||||
if (errors?.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config for ${host}: ${errors.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
credentials = credentialConfigs.map(credentialConfig =>
|
||||
asAzureDevOpsCredential(credentialConfig),
|
||||
);
|
||||
|
||||
if (
|
||||
credentials.some(
|
||||
credential => credential.kind !== 'PersonalAccessToken',
|
||||
) &&
|
||||
host !== AZURE_HOST
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config for ${host}, only personal access tokens can be used with hosts other than ${AZURE_HOST}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.filter(
|
||||
credential =>
|
||||
credential.organizations === undefined ||
|
||||
credential.organizations.length === 0,
|
||||
).length > 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config for ${host}, you cannot specify multiple credentials without organizations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (credential && host !== AZURE_HOST) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, credential can only be used with ${AZURE_HOST}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (credential && token) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, specify either a token or a credential but not both`,
|
||||
);
|
||||
}
|
||||
|
||||
return { host, token, credential };
|
||||
return {
|
||||
host,
|
||||
credentials,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getAzureFileFetchUrl,
|
||||
getAzureDownloadUrl,
|
||||
getAzureRequestOptions,
|
||||
} from './core';
|
||||
import { getAzureFileFetchUrl, getAzureDownloadUrl } from './core';
|
||||
import {
|
||||
AccessToken,
|
||||
ClientSecretCredential,
|
||||
@@ -44,66 +40,6 @@ MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
);
|
||||
|
||||
describe('azure core', () => {
|
||||
describe('getAzureRequestOptions', () => {
|
||||
it('should not add authorization header when not using token or credential', async () => {
|
||||
expect(await getAzureRequestOptions({ host: '' })).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
Authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a personal access token', async () => {
|
||||
expect(
|
||||
await getAzureRequestOptions({ host: '', token: '0123456789' }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Basic OjAxMjM0NTY3ODk=',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a client secret', async () => {
|
||||
expect(
|
||||
await getAzureRequestOptions({
|
||||
host: '',
|
||||
credential: {
|
||||
clientId: 'fake-id',
|
||||
clientSecret: 'fake-secret',
|
||||
tenantId: 'fake-tenant',
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fake-client-secret-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a managed identity', async () => {
|
||||
expect(
|
||||
await getAzureRequestOptions({
|
||||
host: '',
|
||||
credential: {
|
||||
clientId: 'fake-id',
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fake-managed-identity-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAzureFileFetchUrl', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -15,15 +15,6 @@
|
||||
*/
|
||||
|
||||
import { AzureUrl } from './AzureUrl';
|
||||
import {
|
||||
AzureIntegrationConfig,
|
||||
isAzureManagedIdentityCredential,
|
||||
isAzureClientSecretCredential,
|
||||
} from './config';
|
||||
import {
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
|
||||
/**
|
||||
* Given a URL pointing to a file on a provider, returns a URL that is suitable
|
||||
@@ -62,45 +53,3 @@ export function getAzureDownloadUrl(url: string): string {
|
||||
export function getAzureCommitsUrl(url: string): string {
|
||||
return AzureUrl.fromRepoUrl(url).toCommitsUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request options necessary to make requests to a given provider.
|
||||
*
|
||||
* @param config - The relevant provider config
|
||||
* @param additionalHeaders - Additional headers for the request
|
||||
* @public
|
||||
*/
|
||||
export async function getAzureRequestOptions(
|
||||
config: AzureIntegrationConfig,
|
||||
additionalHeaders?: Record<string, string>,
|
||||
): Promise<{ headers: Record<string, string> }> {
|
||||
const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
|
||||
const headers: Record<string, string> = additionalHeaders
|
||||
? { ...additionalHeaders }
|
||||
: {};
|
||||
|
||||
const { token, credential } = config;
|
||||
if (credential) {
|
||||
if (isAzureClientSecretCredential(credential)) {
|
||||
const servicePrincipal = new ClientSecretCredential(
|
||||
credential.tenantId,
|
||||
credential.clientId,
|
||||
credential.clientSecret,
|
||||
);
|
||||
|
||||
const accessToken = await servicePrincipal.getToken(azureDevOpsScope);
|
||||
headers.Authorization = `Bearer ${accessToken.token}`;
|
||||
} else if (isAzureManagedIdentityCredential(credential)) {
|
||||
const managedIdentity = new ManagedIdentityCredential(
|
||||
credential.clientId,
|
||||
);
|
||||
const accessToken = await managedIdentity.getToken(azureDevOpsScope);
|
||||
headers.Authorization = `Bearer ${accessToken.token}`;
|
||||
}
|
||||
} else if (token) {
|
||||
const buffer = Buffer.from(`:${config.token}`, 'utf8');
|
||||
headers.Authorization = `Basic ${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
return { headers };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { getAzureRequestOptions } from './deprecated';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
AccessToken,
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
|
||||
jest.mock('@azure/identity');
|
||||
|
||||
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
|
||||
typeof ClientSecretCredential
|
||||
>;
|
||||
|
||||
const MockedManagedIdentityCredential =
|
||||
ManagedIdentityCredential as jest.MockedClass<
|
||||
typeof ManagedIdentityCredential
|
||||
>;
|
||||
|
||||
describe('azure core', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
|
||||
token: 'fake-client-secret-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
|
||||
token: 'fake-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
});
|
||||
|
||||
describe('getAzureRequestOptions', () => {
|
||||
it('should not add authorization header when not using token or credential', async () => {
|
||||
expect(await getAzureRequestOptions({ host: '' })).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
Authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a personal access token', async () => {
|
||||
const pat = '0123456789';
|
||||
const encoded = Buffer.from(`:${pat}`).toString('base64');
|
||||
expect(
|
||||
await getAzureRequestOptions({
|
||||
host: '',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'PersonalAccessToken',
|
||||
personalAccessToken: pat,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Basic ${encoded}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a client secret', async () => {
|
||||
expect(
|
||||
await getAzureRequestOptions({
|
||||
host: '',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ClientSecret',
|
||||
clientId: 'fake-id',
|
||||
clientSecret: 'fake-secret',
|
||||
tenantId: 'fake-tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fake-client-secret-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add authorization header when using a managed identity', async () => {
|
||||
expect(
|
||||
await getAzureRequestOptions({
|
||||
host: '',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
clientId: 'fake-id',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fake-managed-identity-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AzureIntegrationConfig } from './config';
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
|
||||
/**
|
||||
* Gets the request options necessary to make requests to a given provider.
|
||||
*
|
||||
* @param config - The relevant provider config
|
||||
* @param additionalHeaders - Additional headers for the request
|
||||
* @public
|
||||
* @deprecated Use {@link AzureDevOpsCredentialsProvider} instead.
|
||||
*/
|
||||
export async function getAzureRequestOptions(
|
||||
config: AzureIntegrationConfig,
|
||||
additionalHeaders?: Record<string, string>,
|
||||
): Promise<{ headers: Record<string, string> }> {
|
||||
const headers: Record<string, string> = additionalHeaders
|
||||
? { ...additionalHeaders }
|
||||
: {};
|
||||
|
||||
/*
|
||||
* Since we do not have a way to determine which organization the request is for,
|
||||
* we will use the first credential that does not have an organization specified.
|
||||
*/
|
||||
const credentialConfig = config.credentials?.filter(
|
||||
credential =>
|
||||
credential.organizations === undefined ||
|
||||
credential.organizations.length === 0,
|
||||
)[0];
|
||||
|
||||
if (credentialConfig) {
|
||||
const credentialsProvider =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
|
||||
credentialConfig,
|
||||
);
|
||||
const credentials = await credentialsProvider.getCredentials();
|
||||
|
||||
return {
|
||||
headers: {
|
||||
...credentials?.headers,
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { headers };
|
||||
}
|
||||
@@ -21,13 +21,21 @@ export {
|
||||
} from './config';
|
||||
export type {
|
||||
AzureIntegrationConfig,
|
||||
AzureCredential,
|
||||
AzureManagedIdentityCredential,
|
||||
AzureDevOpsCredentialKind,
|
||||
AzureCredentialBase,
|
||||
AzureClientSecretCredential,
|
||||
AzureManagedIdentityCredential,
|
||||
PersonalAccessTokenCredential,
|
||||
AzureDevOpsCredentialLike,
|
||||
AzureDevOpsCredential,
|
||||
} from './config';
|
||||
export {
|
||||
getAzureCommitsUrl,
|
||||
getAzureDownloadUrl,
|
||||
getAzureFileFetchUrl,
|
||||
getAzureRequestOptions,
|
||||
} from './core';
|
||||
|
||||
export * from './types';
|
||||
export { DefaultAzureDevOpsCredentialsProvider } from './DefaultAzureDevOpsCredentialsProvider';
|
||||
|
||||
export * from './deprecated';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The type of Azure DevOps credential, either bearer or pat.
|
||||
* @public
|
||||
*/
|
||||
export type AzureDevOpsCredentialType = 'bearer' | 'pat';
|
||||
|
||||
/**
|
||||
* A set of credentials for Azure DevOps.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type AzureDevOpsCredentials = {
|
||||
headers: { [name: string]: string };
|
||||
token: string;
|
||||
type: AzureDevOpsCredentialType;
|
||||
};
|
||||
|
||||
/**
|
||||
* This allows implementations to be provided to retrieve Azure DevOps credentials.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
*/
|
||||
export interface AzureDevOpsCredentialsProvider {
|
||||
getCredentials(opts: {
|
||||
url: string;
|
||||
}): Promise<AzureDevOpsCredentials | undefined>;
|
||||
}
|
||||
@@ -18,11 +18,40 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { codeSearch, CodeSearchResponse } from './azure';
|
||||
import {
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('azure', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
const createFixture = (host: string, token: string) => {
|
||||
const azureConfig = {
|
||||
host: host,
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: token,
|
||||
},
|
||||
],
|
||||
};
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
azure: [azureConfig],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
azureConfig: scmIntegrations.azure.byHost(host)?.config!,
|
||||
credentialsProvider:
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations),
|
||||
};
|
||||
};
|
||||
|
||||
describe('codeSearch', () => {
|
||||
it('returns empty when nothing is found', async () => {
|
||||
const response: CodeSearchResponse = { count: 0, results: [] };
|
||||
@@ -42,9 +71,14 @@ describe('azure', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'dev.azure.com',
|
||||
'ABC',
|
||||
);
|
||||
await expect(
|
||||
codeSearch(
|
||||
{ host: 'dev.azure.com', token: 'ABC' },
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'',
|
||||
@@ -96,9 +130,14 @@ describe('azure', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'dev.azure.com',
|
||||
'ABC',
|
||||
);
|
||||
await expect(
|
||||
codeSearch(
|
||||
{ host: 'dev.azure.com', token: 'ABC' },
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'',
|
||||
@@ -140,9 +179,15 @@ describe('azure', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'dev.azure.com',
|
||||
'ABC',
|
||||
);
|
||||
|
||||
await expect(
|
||||
codeSearch(
|
||||
{ host: 'dev.azure.com', token: 'ABC' },
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'backstage',
|
||||
@@ -183,9 +228,15 @@ describe('azure', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'azuredevops.mycompany.com',
|
||||
'ABC',
|
||||
);
|
||||
|
||||
await expect(
|
||||
codeSearch(
|
||||
{ host: 'azuredevops.mycompany.com', token: 'ABC' },
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'',
|
||||
@@ -236,9 +287,15 @@ describe('azure', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'dev.azure.com',
|
||||
'ABC',
|
||||
);
|
||||
|
||||
await expect(
|
||||
codeSearch(
|
||||
{ host: 'dev.azure.com', token: 'ABC' },
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'backstage',
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
AzureDevOpsCredentialsProvider,
|
||||
AzureIntegrationConfig,
|
||||
getAzureRequestOptions,
|
||||
} from '@backstage/integration';
|
||||
|
||||
export interface CodeSearchResponse {
|
||||
@@ -42,6 +42,7 @@ const PAGE_SIZE = 1000;
|
||||
|
||||
// codeSearch returns all files that matches the given search path.
|
||||
export async function codeSearch(
|
||||
credentialsProvider: AzureDevOpsCredentialsProvider,
|
||||
azureConfig: AzureIntegrationConfig,
|
||||
org: string,
|
||||
project: string,
|
||||
@@ -57,10 +58,15 @@ export async function codeSearch(
|
||||
let hasMorePages = true;
|
||||
|
||||
do {
|
||||
const credentials = await credentialsProvider.getCredentials({
|
||||
url: `https://${azureConfig.host}/${org}`,
|
||||
});
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
...(await getAzureRequestOptions(azureConfig, {
|
||||
headers: {
|
||||
...credentials?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
})),
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`,
|
||||
|
||||
+4
@@ -158,6 +158,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
await processor.readLocation(location, false, emitter);
|
||||
|
||||
expect(mockCodeSearch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ host: 'dev.azure.com' },
|
||||
'shopify',
|
||||
'engineering',
|
||||
@@ -207,6 +208,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
await processor.readLocation(location, false, emitter);
|
||||
|
||||
expect(mockCodeSearch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ host: 'dev.azure.com' },
|
||||
'shopify',
|
||||
'engineering',
|
||||
@@ -248,6 +250,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
await processor.readLocation(location, false, emitter);
|
||||
|
||||
expect(mockCodeSearch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ host: 'dev.azure.com' },
|
||||
'shopify',
|
||||
'engineering',
|
||||
@@ -277,6 +280,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
await processor.readLocation(location, false, emitter);
|
||||
|
||||
expect(mockCodeSearch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ host: 'dev.azure.com' },
|
||||
'shopify',
|
||||
'engineering',
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AzureDevOpsCredentialsProvider,
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
ScmIntegrationRegistry,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
@@ -46,6 +48,7 @@ import { codeSearch } from '../lib';
|
||||
*/
|
||||
export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
private readonly integrations: ScmIntegrationRegistry;
|
||||
private readonly credentialsProvider: AzureDevOpsCredentialsProvider;
|
||||
private readonly logger: Logger;
|
||||
|
||||
static fromConfig(config: Config, options: { logger: Logger }) {
|
||||
@@ -63,6 +66,10 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
}) {
|
||||
this.integrations = options.integrations;
|
||||
this.logger = options.logger;
|
||||
this.credentialsProvider =
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
|
||||
options.integrations,
|
||||
);
|
||||
}
|
||||
|
||||
getProcessorName(): string {
|
||||
@@ -93,6 +100,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
);
|
||||
|
||||
const files = await codeSearch(
|
||||
this.credentialsProvider,
|
||||
azureConfig,
|
||||
org,
|
||||
project,
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
|
||||
import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks';
|
||||
import { Config } from '@backstage/config';
|
||||
import { AzureIntegration, ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
AzureDevOpsCredentialsProvider,
|
||||
AzureIntegration,
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import {
|
||||
EntityProvider,
|
||||
EntityProviderConnection,
|
||||
@@ -50,6 +55,9 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
},
|
||||
): AzureDevOpsEntityProvider[] {
|
||||
const providerConfigs = readAzureDevOpsConfigs(configRoot);
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(configRoot);
|
||||
const credentialsProvider =
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations);
|
||||
|
||||
if (!options.schedule && !options.scheduler) {
|
||||
throw new Error('Either schedule or scheduler must be provided.');
|
||||
@@ -79,6 +87,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
return new AzureDevOpsEntityProvider(
|
||||
providerConfig,
|
||||
integration,
|
||||
credentialsProvider,
|
||||
options.logger,
|
||||
taskRunner,
|
||||
);
|
||||
@@ -88,6 +97,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
private constructor(
|
||||
private readonly config: AzureDevOpsConfig,
|
||||
private readonly integration: AzureIntegration,
|
||||
private readonly credentialsProvider: AzureDevOpsCredentialsProvider,
|
||||
logger: Logger,
|
||||
taskRunner: TaskRunner,
|
||||
) {
|
||||
@@ -139,6 +149,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
logger.info('Discovering Azure DevOps catalog files');
|
||||
|
||||
const files = await codeSearch(
|
||||
this.credentialsProvider,
|
||||
this.integration.config,
|
||||
this.config.organization,
|
||||
this.config.project,
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('publish:azure', () => {
|
||||
const config = new ConfigReader({
|
||||
integrations: {
|
||||
azure: [
|
||||
{ host: 'dev.azure.com', token: 'tokenlols' },
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [{ personalAccessToken: 'tokenlols' }],
|
||||
},
|
||||
{ host: 'myazurehostnotoken.com' },
|
||||
],
|
||||
},
|
||||
@@ -115,7 +118,9 @@ describe('publish:azure', () => {
|
||||
'myazurehostnotoken.com?repo=bob&owner=owner&organization=org',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/No token provided for Azure Integration/);
|
||||
).rejects.toThrow(
|
||||
/No credentials provided https:\/\/myazurehostnotoken.com\/org, please check your integrations config/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when no repo is returned', async () => {
|
||||
@@ -260,7 +265,10 @@ describe('publish:azure', () => {
|
||||
const customAuthorConfig = new ConfigReader({
|
||||
integrations: {
|
||||
azure: [
|
||||
{ host: 'dev.azure.com', token: 'tokenlols' },
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [{ personalAccessToken: 'tokenlols' }],
|
||||
},
|
||||
{ host: 'myazurehostnotoken.com' },
|
||||
],
|
||||
},
|
||||
@@ -301,7 +309,10 @@ describe('publish:azure', () => {
|
||||
const customAuthorConfig = new ConfigReader({
|
||||
integrations: {
|
||||
azure: [
|
||||
{ host: 'dev.azure.com', token: 'tokenlols' },
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credentials: [{ personalAccessToken: 'tokenlols' }],
|
||||
},
|
||||
{ host: 'myazurehostnotoken.com' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,10 +15,17 @@
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import {
|
||||
DefaultAzureDevOpsCredentialsProvider,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import { initRepoAndPush } from '../helpers';
|
||||
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
|
||||
import {
|
||||
getBearerHandler,
|
||||
getPersonalAccessTokenHandler,
|
||||
WebApi,
|
||||
} from 'azure-devops-node-api';
|
||||
import { getRepoSourceDirectory, parseRepoUrl } from './util';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { Config } from '@backstage/config';
|
||||
@@ -135,22 +142,23 @@ export function createPublishAzureAction(options: {
|
||||
);
|
||||
}
|
||||
|
||||
const integrationConfig = integrations.azure.byHost(host);
|
||||
const url = `https://${host}/${organization}`;
|
||||
const credentialProvider =
|
||||
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations);
|
||||
const credentials = await credentialProvider.getCredentials({ url: url });
|
||||
|
||||
if (!integrationConfig) {
|
||||
if (credentials === undefined && ctx.input.token === undefined) {
|
||||
throw new InputError(
|
||||
`No matching integration configuration for host ${host}, please check your integrations config`,
|
||||
`No credentials provided ${url}, please check your integrations config`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!integrationConfig.config.token && !ctx.input.token) {
|
||||
throw new InputError(`No token provided for Azure Integration ${host}`);
|
||||
}
|
||||
const authHandler =
|
||||
ctx.input.token || credentials?.type === 'pat'
|
||||
? getPersonalAccessTokenHandler(ctx.input.token ?? credentials!.token)
|
||||
: getBearerHandler(credentials!.token);
|
||||
|
||||
const token = ctx.input.token ?? integrationConfig.config.token!;
|
||||
const authHandler = getPersonalAccessTokenHandler(token);
|
||||
|
||||
const webApi = new WebApi(`https://${host}/${organization}`, authHandler);
|
||||
const webApi = new WebApi(url, authHandler);
|
||||
const client = await webApi.getGitApi();
|
||||
const createOptions: GitRepositoryCreateOptions = { name: repo };
|
||||
const returnedRepo = await client.createRepository(createOptions, owner);
|
||||
@@ -187,14 +195,19 @@ export function createPublishAzureAction(options: {
|
||||
: config.getOptionalString('scaffolder.defaultAuthor.email'),
|
||||
};
|
||||
|
||||
const auth =
|
||||
ctx.input.token || credentials?.type === 'pat'
|
||||
? {
|
||||
username: 'notempty',
|
||||
password: ctx.input.token ?? credentials!.token,
|
||||
}
|
||||
: { token: credentials!.token };
|
||||
|
||||
const commitResult = await initRepoAndPush({
|
||||
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
|
||||
remoteUrl,
|
||||
defaultBranch,
|
||||
auth: {
|
||||
username: 'notempty',
|
||||
password: token,
|
||||
},
|
||||
auth: auth,
|
||||
logger: ctx.logger,
|
||||
commitMessage: gitCommitMessage
|
||||
? gitCommitMessage
|
||||
|
||||
Reference in New Issue
Block a user