diff --git a/.changeset/hungry-dots-design.md b/.changeset/hungry-dots-design.md new file mode 100644 index 0000000000..0dd8d415e9 --- /dev/null +++ b/.changeset/hungry-dots-design.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend-module-azure': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/backend-common': patch +--- + +Use `DefaultAzureDevOpsCredentialsProvider` to retrieve credentials for Azure DevOps. diff --git a/.changeset/six-wolves-admire.md b/.changeset/six-wolves-admire.md new file mode 100644 index 0000000000..efc80826c2 --- /dev/null +++ b/.changeset/six-wolves-admire.md @@ -0,0 +1,23 @@ +--- +'@backstage/integration': minor +--- + +Added `AzureDevOpsCredentialsProvider` to support multiple Azure DevOps organizations and **deprecated** `AzureIntegrationConfig.credential` and `AzureIntegrationConfig.token` in favour of `AzureIntegrationConfig.credentials`. You can now use specific credentials for different Azure DevOps (Server) organizations by specifying the `organizations` field on a credential: + +```yaml +integrations: + azure: + - host: dev.azure.com + credentials: + - organizations: + - my-org + - my-other-org + clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} + tenantId: ${AZURE_TENANT_ID} + - organizations: + - yet-another-org + personalAccessToken: ${PERSONAL_ACCESS_TOKEN} +``` + +See the [Azure integration documentation](https://backstage.io/docs/integrations/azure/locations) for more information. diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md index 5647a6cba4..7553df4081 100644 --- a/docs/integrations/azure/locations.md +++ b/docs/integrations/azure/locations.md @@ -19,10 +19,10 @@ Using a service principal: integrations: azure: - host: dev.azure.com - credential: - clientId: ${CLIENT_ID} - clientSecret: ${CLIENT_SECRET} - tenantId: ${TENANT_ID} + credentials: + - clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} + tenantId: ${AZURE_TENANT_ID} ``` Using a managed identity: @@ -31,8 +31,8 @@ Using a managed identity: integrations: azure: - host: dev.azure.com - credential: - clientId: ${CLIENT_ID} + credentials: + - clientId: ${AZURE_CLIENT_ID} ``` Using a personal access token (PAT): @@ -41,24 +41,54 @@ Using a personal access token (PAT): integrations: azure: - host: dev.azure.com - token: ${AZURE_TOKEN} + credentials: + - personalAccessToken: ${PERSONAL_ACCESS_TOKEN} ``` +You can use specific credentials for different Azure DevOps organizations by specifying the `organizations` field on the credential: + +```yaml +integrations: + azure: + - host: dev.azure.com + credentials: + - organizations: + - my-org + - my-other-org + clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} + tenantId: ${AZURE_TENANT_ID} + - organizations: + - another-org + clientId: ${AZURE_CLIENT_ID} + - organizations: + - yet-another-org + personalAccessToken: ${PERSONAL_ACCESS_TOKEN} +``` + +If you do not specify the `organizations` field the credential will be used for all organizations for which no other credential is configured. + > Note: An Azure DevOps provider is added automatically at startup for > convenience, so you only need to list it if you want to supply a -> [token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate), +> [personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate), > a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity), > or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity) The configuration is a structure with these elements: -- `host`: The DevOps host; only `dev.azure.com` is supported. -- `token` (optional): A personal access token as expected by Azure DevOps. -- `credential`: (optional): A service principal or managed identity +- `credentials`: (optional): A service principal, managed identity, or personal access token + +The `credentials` element is a structure with these elements: + +- `organizations`: (optional): A list of organizations for which this credential should be used. If not specified the credential will be used for all organizations for which no other credential is configured. +- `clientId`: The client ID of the service principal or managed identity (required for service principal and managed identities) +- `clientSecret`: The client secret of the service principal (required for service principal) +- `tenantId`: The tenant ID of the service principal (required for service principal) +- `personalAccessToken`: The personal access token (required for personal access token) > Note: > -> - `token` and `credential` are mutually exclusive. > - You cannot use a service principal or managed identity for Azure DevOps Server (on-premises) organizations > - You can only use a service principal or managed identity for Azure AD backed Azure DevOps organizations -> - The token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API +> - You can only specify one credential per host without any organizations specified +> - The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index f8e535c8a7..f0fa15efb0 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -9,6 +9,7 @@ import { AppConfig } from '@backstage/config'; import { AwsCredentialsManager } from '@backstage/integration-aws-node'; import { AwsS3Integration } from '@backstage/integration'; +import { AzureDevOpsCredentialsProvider } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BitbucketCloudIntegration } from '@backstage/integration'; @@ -94,6 +95,7 @@ export class AzureUrlReader implements UrlReader { integration: AzureIntegration, deps: { treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: AzureDevOpsCredentialsProvider; }, ); // (undocumented) diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index f969b3b7eb..6b2f41a2bf 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -17,7 +17,11 @@ import { ConfigReader } from '@backstage/config'; import { AzureIntegration, + DefaultAzureDevOpsCredentialsProvider, readAzureIntegrationConfig, + ScmIntegrations, + AzureDevOpsCredentialLike, + AzureIntegrationConfig, } from '@backstage/integration'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import fs from 'fs-extra'; @@ -31,12 +35,40 @@ import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; import { DefaultReadTreeResponseFactory } from './tree'; +type AzureIntegrationConfigLike = Partial< + Omit +> & { + credentials?: Partial[]; +}; + const logger = getVoidLogger(); const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const urlReaderFactory = (azureIntegration: AzureIntegrationConfigLike) => { + const credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations( + ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [azureIntegration], + }, + }), + ), + ); + return new AzureUrlReader( + new AzureIntegration( + readAzureIntegrationConfig(new ConfigReader(azureIntegration)), + ), + { + treeResponseFactory, + credentialsProvider, + }, + ); +}; + const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; describe('AzureUrlReader', () => { @@ -68,13 +100,29 @@ describe('AzureUrlReader', () => { ); }); - const createConfig = (token?: string) => - new ConfigReader( + const createConfig = (token?: string) => { + let credentials: AzureDevOpsCredentialLike[] | undefined = undefined; + if (token !== undefined) { + credentials = [ + { + personalAccessToken: token, + }, + ]; + } + return new ConfigReader( { - integrations: { azure: [{ host: 'dev.azure.com', token }] }, + integrations: { + azure: [ + { + host: 'dev.azure.com', + credentials: credentials, + }, + ], + }, }, 'test-config', ); + }; it.each([ { @@ -104,8 +152,8 @@ describe('AzureUrlReader', () => { url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml', config: createConfig(undefined), response: expect.objectContaining({ - headers: expect.not.objectContaining({ - authorization: expect.anything(), + headers: expect.objectContaining({ + authorization: expect.stringMatching(/^Bearer /), }), }), }, @@ -137,7 +185,7 @@ describe('AzureUrlReader', () => { url: '', config: createConfig(''), error: - "Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string", + "Invalid type in config for key 'integrations.azure[0].credentials[0].personalAccessToken' in 'test-config', got empty-string, wanted string", }, ])('should handle error path %#', async ({ url, config, error }) => { await expect(async () => { @@ -156,16 +204,14 @@ describe('AzureUrlReader', () => { path.resolve(__dirname, '__fixtures__/mock-main.zip'), ); - const processor = new AzureUrlReader( - new AzureIntegration( - readAzureIntegrationConfig( - new ConfigReader({ - host: 'dev.azure.com', - }), - ), - ), - { treeResponseFactory }, - ); + const processor = urlReaderFactory({ + host: 'dev.azure.com', + credentials: [ + { + personalAccessToken: 'my-pat', + }, + ], + }); beforeEach(() => { worker.use( @@ -268,16 +314,14 @@ describe('AzureUrlReader', () => { path.resolve(__dirname, '__fixtures__/mock-main.zip'), ); - const processor = new AzureUrlReader( - new AzureIntegration( - readAzureIntegrationConfig( - new ConfigReader({ - host: 'dev.azure.com', - }), - ), - ), - { treeResponseFactory }, - ); + const processor = urlReaderFactory({ + host: 'dev.azure.com', + credentials: [ + { + personalAccessToken: 'my-pat', + }, + ], + }); beforeEach(() => { worker.use( diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 94abf3a178..783736b639 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -15,12 +15,13 @@ */ import { - AzureIntegration, getAzureCommitsUrl, getAzureDownloadUrl, getAzureFileFetchUrl, - getAzureRequestOptions, + AzureDevOpsCredentialsProvider, + DefaultAzureDevOpsCredentialsProvider, ScmIntegrations, + AzureIntegration, } from '@backstage/integration'; import fetch, { Response } from 'node-fetch'; import { Minimatch } from 'minimatch'; @@ -47,8 +48,13 @@ import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const integrations = ScmIntegrations.fromConfig(config); + const credentialProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations); return integrations.azure.list().map(integration => { - const reader = new AzureUrlReader(integration, { treeResponseFactory }); + const reader = new AzureUrlReader(integration, { + treeResponseFactory, + credentialsProvider: credentialProvider, + }); const predicate = (url: URL) => url.host === integration.config.host; return { reader, predicate }; }); @@ -56,7 +62,10 @@ export class AzureUrlReader implements UrlReader { constructor( private readonly integration: AzureIntegration, - private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + private readonly deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: AzureDevOpsCredentialsProvider; + }, ) {} async read(url: string): Promise { @@ -72,11 +81,13 @@ export class AzureUrlReader implements UrlReader { const { signal } = options ?? {}; const builtUrl = getAzureFileFetchUrl(url); - let response: Response; try { + const credentials = await this.deps.credentialsProvider.getCredentials({ + url: builtUrl, + }); response = await fetch(builtUrl, { - ...(await getAzureRequestOptions(this.integration.config)), + headers: credentials?.headers, // TODO(freben): The signal cast is there because pre-3.x versions of // node-fetch have a very slightly deviating AbortSignal type signature. // The difference does not affect us in practice however. The cast can @@ -111,10 +122,13 @@ export class AzureUrlReader implements UrlReader { // Get latest commit SHA - const commitsAzureResponse = await fetch( - getAzureCommitsUrl(url), - await getAzureRequestOptions(this.integration.config), - ); + const credentials = await this.deps.credentialsProvider.getCredentials({ + url: url, + }); + + const commitsAzureResponse = await fetch(getAzureCommitsUrl(url), { + headers: credentials?.headers, + }); if (!commitsAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`; if (commitsAzureResponse.status === 404) { @@ -129,9 +143,10 @@ export class AzureUrlReader implements UrlReader { } const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), { - ...(await getAzureRequestOptions(this.integration.config, { + headers: { + ...credentials?.headers, Accept: 'application/zip', - })), + }, // TODO(freben): The signal cast is there because pre-3.x versions of // node-fetch have a very slightly deviating AbortSignal type signature. // The difference does not affect us in practice however. The cast can be @@ -198,7 +213,9 @@ export class AzureUrlReader implements UrlReader { } toString() { - const { host, token } = this.integration.config; - return `azure{host=${host},authed=${Boolean(token)}}`; + const { host, credentials } = this.integration.config; + return `azure{host=${host},authed=${Boolean( + credentials !== undefined && credentials.length > 0, + )}}`; } } diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 7266950ac4..f64c06257e 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -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 & + Partial & + Partial, + 'kind' +>; + +// @public +export type AzureDevOpsCredentials = { + headers: { + [name: string]: string; + }; + token: string; + type: AzureDevOpsCredentialType; +}; + +// @public +export interface AzureDevOpsCredentialsProvider { + // (undocumented) + getCredentials(opts: { + url: string; + }): Promise; +} + +// @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; +} + // @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, @@ -600,6 +658,12 @@ export function parseGerritGitilesUrl( // @public export function parseGerritJsonResponse(response: Response): Promise; +// @public +export type PersonalAccessTokenCredential = AzureCredentialBase & { + kind: 'PersonalAccessToken'; + personalAccessToken: string; +}; + // @public export function readAwsS3IntegrationConfig( config: Config, diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a1eb7aceb9..9c08ec03a9 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -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; + }[]; }>; /** diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index d036d88c21..fe2cbf349c 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -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, diff --git a/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.test.ts b/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.test.ts new file mode 100644 index 0000000000..c9fbb4c324 --- /dev/null +++ b/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.test.ts @@ -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); + }); +}); diff --git a/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.ts b/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.ts new file mode 100644 index 0000000000..aee4cefc41 --- /dev/null +++ b/packages/integration/src/azure/CachedAzureDevOpsCredentialsProvider.ts @@ -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 { + 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; + } +} diff --git a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts new file mode 100644 index 0000000000..8f66d5742d --- /dev/null +++ b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts @@ -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 +> & { + credential?: Partial; + credentials?: Partial[]; +}; + +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(); + }); + }); + }); + }); + }); +}); diff --git a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts new file mode 100644 index 0000000000..1ff35b0612 --- /dev/null +++ b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts @@ -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()); + + 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 { + 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); + } +} diff --git a/packages/integration/src/azure/config.test.ts b/packages/integration/src/azure/config.test.ts index 54711e4ddb..eb36120c32 100644 --- a/packages/integration/src/azure/config.test.ts +++ b/packages/integration/src/azure/config.test.ts @@ -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 +> & { + credential?: Partial; + credentials?: Partial[]; +}; + describe('readAzureIntegrationConfig', () => { - function buildConfig(data: Partial): Config { + const valid: any = { + host: 'dev.azure.com', + }; + + function buildConfig(data: AzureIntegrationConfigLike): Config { return new ConfigReader(data); } async function buildFrontendConfig( - data: Partial, + data: AzureIntegrationConfigLike, ): Promise { 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[]): 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', () => { diff --git a/packages/integration/src/azure/config.ts b/packages/integration/src/azure/config.ts index 3f12f3ef13..98704ba570 100644 --- a/packages/integration/src/azure/config.ts +++ b/packages/integration/src/azure/config.ts @@ -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 & + Partial & + Partial, + 'kind' +>; + +/** + * Credential used to authenticate to Azure DevOps. + * @public + */ +export type AzureDevOpsCredential = | AzureClientSecretCredential - | AzureManagedIdentityCredential; -export const isAzureClientSecretCredential = ( - credential: Partial, -): 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, -): 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), + } 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 = { + 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('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()) + .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), + ) + .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()), + ); + + 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, + }; } /** diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts index e6ef5401ac..601057e499 100644 --- a/packages/integration/src/azure/core.test.ts +++ b/packages/integration/src/azure/core.test.ts @@ -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([ { diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 31aa13d91f..1c25373962 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -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, -): Promise<{ headers: Record }> { - const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default'; - const headers: Record = 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 }; -} diff --git a/packages/integration/src/azure/deprecated.test.ts b/packages/integration/src/azure/deprecated.test.ts new file mode 100644 index 0000000000..4411ef5f2f --- /dev/null +++ b/packages/integration/src/azure/deprecated.test.ts @@ -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', + }), + }), + ); + }); + }); +}); diff --git a/packages/integration/src/azure/deprecated.ts b/packages/integration/src/azure/deprecated.ts new file mode 100644 index 0000000000..fd19f3dc14 --- /dev/null +++ b/packages/integration/src/azure/deprecated.ts @@ -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, +): Promise<{ headers: Record }> { + const headers: Record = 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 }; +} diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index 21cf069807..b5f5c0a316 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -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'; diff --git a/packages/integration/src/azure/types.ts b/packages/integration/src/azure/types.ts new file mode 100644 index 0000000000..72b341432c --- /dev/null +++ b/packages/integration/src/azure/types.ts @@ -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; +} diff --git a/plugins/catalog-backend-module-azure/src/lib/azure.test.ts b/plugins/catalog-backend-module-azure/src/lib/azure.test.ts index f26208353a..a6e5fb00c1 100644 --- a/plugins/catalog-backend-module-azure/src/lib/azure.test.ts +++ b/plugins/catalog-backend-module-azure/src/lib/azure.test.ts @@ -18,11 +18,40 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { codeSearch, CodeSearchResponse } from './azure'; +import { + DefaultAzureDevOpsCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; describe('azure', () => { const server = setupServer(); setupRequestMockHandlers(server); + const createFixture = (host: string, token: string) => { + const azureConfig = { + host: host, + credentials: [ + { + personalAccessToken: token, + }, + ], + }; + const scmIntegrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [azureConfig], + }, + }), + ); + + return { + azureConfig: scmIntegrations.azure.byHost(host)?.config!, + credentialsProvider: + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations), + }; + }; + describe('codeSearch', () => { it('returns empty when nothing is found', async () => { const response: CodeSearchResponse = { count: 0, results: [] }; @@ -42,9 +71,14 @@ describe('azure', () => { ), ); + const { credentialsProvider, azureConfig } = createFixture( + 'dev.azure.com', + 'ABC', + ); await expect( codeSearch( - { host: 'dev.azure.com', token: 'ABC' }, + credentialsProvider, + azureConfig, 'shopify', 'engineering', '', @@ -96,9 +130,14 @@ describe('azure', () => { ), ); + const { credentialsProvider, azureConfig } = createFixture( + 'dev.azure.com', + 'ABC', + ); await expect( codeSearch( - { host: 'dev.azure.com', token: 'ABC' }, + credentialsProvider, + azureConfig, 'shopify', 'engineering', '', @@ -140,9 +179,15 @@ describe('azure', () => { ), ); + const { credentialsProvider, azureConfig } = createFixture( + 'dev.azure.com', + 'ABC', + ); + await expect( codeSearch( - { host: 'dev.azure.com', token: 'ABC' }, + credentialsProvider, + azureConfig, 'shopify', 'engineering', 'backstage', @@ -183,9 +228,15 @@ describe('azure', () => { ), ); + const { credentialsProvider, azureConfig } = createFixture( + 'azuredevops.mycompany.com', + 'ABC', + ); + await expect( codeSearch( - { host: 'azuredevops.mycompany.com', token: 'ABC' }, + credentialsProvider, + azureConfig, 'shopify', 'engineering', '', @@ -236,9 +287,15 @@ describe('azure', () => { ), ); + const { credentialsProvider, azureConfig } = createFixture( + 'dev.azure.com', + 'ABC', + ); + await expect( codeSearch( - { host: 'dev.azure.com', token: 'ABC' }, + credentialsProvider, + azureConfig, 'shopify', 'engineering', 'backstage', diff --git a/plugins/catalog-backend-module-azure/src/lib/azure.ts b/plugins/catalog-backend-module-azure/src/lib/azure.ts index 916d70a9fa..fc31c7855b 100644 --- a/plugins/catalog-backend-module-azure/src/lib/azure.ts +++ b/plugins/catalog-backend-module-azure/src/lib/azure.ts @@ -16,8 +16,8 @@ import fetch from 'node-fetch'; import { + AzureDevOpsCredentialsProvider, AzureIntegrationConfig, - getAzureRequestOptions, } from '@backstage/integration'; export interface CodeSearchResponse { @@ -42,6 +42,7 @@ const PAGE_SIZE = 1000; // codeSearch returns all files that matches the given search path. export async function codeSearch( + credentialsProvider: AzureDevOpsCredentialsProvider, azureConfig: AzureIntegrationConfig, org: string, project: string, @@ -57,10 +58,15 @@ export async function codeSearch( let hasMorePages = true; do { + const credentials = await credentialsProvider.getCredentials({ + url: `https://${azureConfig.host}/${org}`, + }); + const response = await fetch(searchUrl, { - ...(await getAzureRequestOptions(azureConfig, { + headers: { + ...credentials?.headers, 'Content-Type': 'application/json', - })), + }, method: 'POST', body: JSON.stringify({ searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`, diff --git a/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.test.ts b/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.test.ts index 4a8e1d4537..36dd920621 100644 --- a/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.test.ts @@ -158,6 +158,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { await processor.readLocation(location, false, emitter); expect(mockCodeSearch).toHaveBeenCalledWith( + expect.anything(), { host: 'dev.azure.com' }, 'shopify', 'engineering', @@ -207,6 +208,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { await processor.readLocation(location, false, emitter); expect(mockCodeSearch).toHaveBeenCalledWith( + expect.anything(), { host: 'dev.azure.com' }, 'shopify', 'engineering', @@ -248,6 +250,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { await processor.readLocation(location, false, emitter); expect(mockCodeSearch).toHaveBeenCalledWith( + expect.anything(), { host: 'dev.azure.com' }, 'shopify', 'engineering', @@ -277,6 +280,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => { await processor.readLocation(location, false, emitter); expect(mockCodeSearch).toHaveBeenCalledWith( + expect.anything(), { host: 'dev.azure.com' }, 'shopify', 'engineering', diff --git a/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.ts b/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.ts index bad6067dc3..d9638e6cc7 100644 --- a/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.ts +++ b/plugins/catalog-backend-module-azure/src/processors/AzureDevOpsDiscoveryProcessor.ts @@ -16,6 +16,8 @@ import { Config } from '@backstage/config'; import { + AzureDevOpsCredentialsProvider, + DefaultAzureDevOpsCredentialsProvider, ScmIntegrationRegistry, ScmIntegrations, } from '@backstage/integration'; @@ -46,6 +48,7 @@ import { codeSearch } from '../lib'; */ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { private readonly integrations: ScmIntegrationRegistry; + private readonly credentialsProvider: AzureDevOpsCredentialsProvider; private readonly logger: Logger; static fromConfig(config: Config, options: { logger: Logger }) { @@ -63,6 +66,10 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { }) { this.integrations = options.integrations; this.logger = options.logger; + this.credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations( + options.integrations, + ); } getProcessorName(): string { @@ -93,6 +100,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor { ); const files = await codeSearch( + this.credentialsProvider, azureConfig, org, project, diff --git a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts index 4a9ad2f250..92398779c7 100644 --- a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts +++ b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.ts @@ -16,7 +16,12 @@ import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks'; import { Config } from '@backstage/config'; -import { AzureIntegration, ScmIntegrations } from '@backstage/integration'; +import { + AzureDevOpsCredentialsProvider, + AzureIntegration, + DefaultAzureDevOpsCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; import { EntityProvider, EntityProviderConnection, @@ -50,6 +55,9 @@ export class AzureDevOpsEntityProvider implements EntityProvider { }, ): AzureDevOpsEntityProvider[] { const providerConfigs = readAzureDevOpsConfigs(configRoot); + const scmIntegrations = ScmIntegrations.fromConfig(configRoot); + const credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); if (!options.schedule && !options.scheduler) { throw new Error('Either schedule or scheduler must be provided.'); @@ -79,6 +87,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider { return new AzureDevOpsEntityProvider( providerConfig, integration, + credentialsProvider, options.logger, taskRunner, ); @@ -88,6 +97,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider { private constructor( private readonly config: AzureDevOpsConfig, private readonly integration: AzureIntegration, + private readonly credentialsProvider: AzureDevOpsCredentialsProvider, logger: Logger, taskRunner: TaskRunner, ) { @@ -139,6 +149,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider { logger.info('Discovering Azure DevOps catalog files'); const files = await codeSearch( + this.credentialsProvider, this.integration.config, this.config.organization, this.config.project, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index 9f68eb848a..9b7673ea29 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -42,7 +42,10 @@ describe('publish:azure', () => { const config = new ConfigReader({ integrations: { azure: [ - { host: 'dev.azure.com', token: 'tokenlols' }, + { + host: 'dev.azure.com', + credentials: [{ personalAccessToken: 'tokenlols' }], + }, { host: 'myazurehostnotoken.com' }, ], }, @@ -115,7 +118,9 @@ describe('publish:azure', () => { 'myazurehostnotoken.com?repo=bob&owner=owner&organization=org', }, }), - ).rejects.toThrow(/No token provided for Azure Integration/); + ).rejects.toThrow( + /No credentials provided https:\/\/myazurehostnotoken.com\/org, please check your integrations config/, + ); }); it('should throw when no repo is returned', async () => { @@ -260,7 +265,10 @@ describe('publish:azure', () => { const customAuthorConfig = new ConfigReader({ integrations: { azure: [ - { host: 'dev.azure.com', token: 'tokenlols' }, + { + host: 'dev.azure.com', + credentials: [{ personalAccessToken: 'tokenlols' }], + }, { host: 'myazurehostnotoken.com' }, ], }, @@ -301,7 +309,10 @@ describe('publish:azure', () => { const customAuthorConfig = new ConfigReader({ integrations: { azure: [ - { host: 'dev.azure.com', token: 'tokenlols' }, + { + host: 'dev.azure.com', + credentials: [{ personalAccessToken: 'tokenlols' }], + }, { host: 'myazurehostnotoken.com' }, ], }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 96b7c87204..b6c0226163 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -15,10 +15,17 @@ */ import { InputError } from '@backstage/errors'; -import { ScmIntegrationRegistry } from '@backstage/integration'; +import { + DefaultAzureDevOpsCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; import { initRepoAndPush } from '../helpers'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import { + getBearerHandler, + getPersonalAccessTokenHandler, + WebApi, +} from 'azure-devops-node-api'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; @@ -135,22 +142,23 @@ export function createPublishAzureAction(options: { ); } - const integrationConfig = integrations.azure.byHost(host); + const url = `https://${host}/${organization}`; + const credentialProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations); + const credentials = await credentialProvider.getCredentials({ url: url }); - if (!integrationConfig) { + if (credentials === undefined && ctx.input.token === undefined) { throw new InputError( - `No matching integration configuration for host ${host}, please check your integrations config`, + `No credentials provided ${url}, please check your integrations config`, ); } - if (!integrationConfig.config.token && !ctx.input.token) { - throw new InputError(`No token provided for Azure Integration ${host}`); - } + const authHandler = + ctx.input.token || credentials?.type === 'pat' + ? getPersonalAccessTokenHandler(ctx.input.token ?? credentials!.token) + : getBearerHandler(credentials!.token); - const token = ctx.input.token ?? integrationConfig.config.token!; - const authHandler = getPersonalAccessTokenHandler(token); - - const webApi = new WebApi(`https://${host}/${organization}`, authHandler); + const webApi = new WebApi(url, authHandler); const client = await webApi.getGitApi(); const createOptions: GitRepositoryCreateOptions = { name: repo }; const returnedRepo = await client.createRepository(createOptions, owner); @@ -187,14 +195,19 @@ export function createPublishAzureAction(options: { : config.getOptionalString('scaffolder.defaultAuthor.email'), }; + const auth = + ctx.input.token || credentials?.type === 'pat' + ? { + username: 'notempty', + password: ctx.input.token ?? credentials!.token, + } + : { token: credentials!.token }; + const commitResult = await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), remoteUrl, defaultBranch, - auth: { - username: 'notempty', - password: token, - }, + auth: auth, logger: ctx.logger, commitMessage: gitCommitMessage ? gitCommitMessage