feature(azure devops): support multiple organisations
Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user