feature(azure devops): support multiple organisations

Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
Sander Aernouts
2023-06-12 16:37:22 +02:00
parent b3d14f8112
commit 5f1a92b9f1
28 changed files with 2387 additions and 363 deletions
+70 -6
View File
@@ -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,
+30
View File
@@ -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);
}
}
+571 -105
View File
@@ -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', () => {
+243 -51
View File
@@ -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,
};
}
/**
+1 -65
View File
@@ -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([
{
-51
View File
@@ -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 };
}
+11 -3
View File
@@ -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';
+44
View File
@@ -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>;
}