From c7f848bcea3c23cc492204fe8ef6470b39fe3604 Mon Sep 17 00:00:00 2001 From: Sander Aernouts Date: Fri, 12 May 2023 16:08:47 +0200 Subject: [PATCH] feat(azure): support service principals and managed identities Signed-off-by: Sander Aernouts --- .changeset/orange-taxis-exercise.md | 9 ++ docs/integrations/azure/locations.md | 38 +++++- .../src/reading/AzureUrlReader.ts | 8 +- packages/integration/api-report.md | 20 ++- packages/integration/package.json | 1 + packages/integration/src/azure/config.test.ts | 125 +++++++++++++++++- packages/integration/src/azure/config.ts | 97 +++++++++++++- packages/integration/src/azure/core.test.ts | 74 ++++++++++- packages/integration/src/azure/core.ts | 36 ++++- packages/integration/src/azure/index.ts | 7 +- .../src/lib/azure.ts | 4 +- yarn.lock | 54 ++++++-- 12 files changed, 436 insertions(+), 37 deletions(-) create mode 100644 .changeset/orange-taxis-exercise.md diff --git a/.changeset/orange-taxis-exercise.md b/.changeset/orange-taxis-exercise.md new file mode 100644 index 0000000000..59a52b74bc --- /dev/null +++ b/.changeset/orange-taxis-exercise.md @@ -0,0 +1,9 @@ +--- +'@backstage/backend-common': minor +'@backstage/integration': minor +'@backstage/plugin-catalog-backend-module-azure': patch +--- + +Support authentication with a service principal or managed identity for Azure DevOps + +Azure DevOps recently released support, in public preview, for authenticating with a service principal or managed identity instead of a personal access token (PAT): https://devblogs.microsoft.com/devops/introducing-service-principal-and-managed-identity-support-on-azure-devops/. With this change the Azure integration now supports service principals and managed identities for Azure AD backed Azure DevOps organizations. Service principal and managed identity authentication is not supported on Azure DevOps Server (on-premises) organizations. diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md index 1fc476f418..5647a6cba4 100644 --- a/docs/integrations/azure/locations.md +++ b/docs/integrations/azure/locations.md @@ -13,6 +13,30 @@ or registered with the [catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) plugin. +Using a service principal: + +```yaml +integrations: + azure: + - host: dev.azure.com + credential: + clientId: ${CLIENT_ID} + clientSecret: ${CLIENT_SECRET} + tenantId: ${TENANT_ID} +``` + +Using a managed identity: + +```yaml +integrations: + azure: + - host: dev.azure.com + credential: + clientId: ${CLIENT_ID} +``` + +Using a personal access token (PAT): + ```yaml integrations: azure: @@ -22,11 +46,19 @@ integrations: > 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). +> [token](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 two elements: +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 -> Note: 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 +> 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 diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 90cf8f29bd..26fe3f4a12 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -76,7 +76,7 @@ export class AzureUrlReader implements UrlReader { let response: Response; try { response = await fetch(builtUrl, { - ...getAzureRequestOptions(this.integration.config), + ...(await getAzureRequestOptions(this.integration.config)), // 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 @@ -113,7 +113,7 @@ export class AzureUrlReader implements UrlReader { const commitsAzureResponse = await fetch( getAzureCommitsUrl(url), - getAzureRequestOptions(this.integration.config), + await getAzureRequestOptions(this.integration.config), ); if (!commitsAzureResponse.ok) { const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`; @@ -129,9 +129,9 @@ export class AzureUrlReader implements UrlReader { } const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), { - ...getAzureRequestOptions(this.integration.config, { + ...(await getAzureRequestOptions(this.integration.config, { 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 diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index d45c8ca06b..2d7ac22014 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -38,6 +38,9 @@ export type AwsS3IntegrationConfig = { externalId?: string; }; +// @public +export type AzureCredential = ClientSecret | ManagedIdentity; + // @public export class AzureIntegration implements ScmIntegration { constructor(integrationConfig: AzureIntegrationConfig); @@ -63,6 +66,7 @@ export class AzureIntegration implements ScmIntegration { export type AzureIntegrationConfig = { host: string; token?: string; + credential?: AzureCredential; }; // @public @@ -154,6 +158,13 @@ export type BitbucketServerIntegrationConfig = { password?: string; }; +// @public +export type ClientSecret = { + tenantId: string; + clientId: string; + clientSecret: string; +}; + // @public export class DefaultGithubCredentialsProvider implements GithubCredentialsProvider @@ -216,9 +227,9 @@ export function getAzureFileFetchUrl(url: string): string; export function getAzureRequestOptions( config: AzureIntegrationConfig, additionalHeaders?: Record, -): { +): Promise<{ headers: Record; -}; +}>; // @public export function getBitbucketCloudDefaultBranch( @@ -535,6 +546,11 @@ export interface IntegrationsByType { gitlab: ScmIntegrationsGroup; } +// @public +export type ManagedIdentity = { + clientId: string; +}; + // @public export function parseGerritGitilesUrl( config: GerritIntegrationConfig, diff --git a/packages/integration/package.json b/packages/integration/package.json index fbe8ac1fc3..677d3c6992 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -32,6 +32,7 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@azure/identity": "^3.2.1", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@octokit/auth-app": "^4.0.0", diff --git a/packages/integration/src/azure/config.test.ts b/packages/integration/src/azure/config.test.ts index 353065a030..54711e4ddb 100644 --- a/packages/integration/src/azure/config.test.ts +++ b/packages/integration/src/azure/config.test.ts @@ -51,19 +51,42 @@ describe('readAzureIntegrationConfig', () => { return new ConfigReader((processed[0].data as any).integrations.azure[0]); } - it('reads all values', () => { + 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', () => { + const output = readAzureIntegrationConfig( + buildConfig({ + host: 'dev.azure.com', + credential: { + clientId: 'id', + clientSecret: 'secret', + tenantId: 'tenant', + }, + }), + ); + + expect(output).toEqual({ + host: 'dev.azure.com', + credential: { + clientId: 'id', + clientSecret: 'secret', + tenantId: 'tenant', + }, + }); + }); + it('inserts the defaults if missing', () => { const output = readAzureIntegrationConfig(buildConfig({})); expect(output).toEqual({ host: 'dev.azure.com' }); @@ -71,15 +94,86 @@ describe('readAzureIntegrationConfig', () => { it('rejects funky configs', () => { const valid: any = { - host: 'a.com', - token: 't', + host: 'dev.azure.com', }; + 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/); + + expect(() => + readAzureIntegrationConfig( + buildConfig({ + ...valid, + credential: { clientId: 'id', clientSecret: 'secret' }, + }), + ), + ).toThrow(/credential/); + + expect(() => + readAzureIntegrationConfig( + buildConfig({ + ...valid, + credential: { clientId: 'id', tenantId: 'tenant' }, + }), + ), + ).toThrow(/credential/); + + expect(() => + readAzureIntegrationConfig( + buildConfig({ + ...valid, + host: 'a.com', + credential: { + clientId: 'id', + tenantId: 'tenant', + clientSecret: 'secret', + }, + }), + ), + ).toThrow(/credential/); }); it('works on the frontend', async () => { @@ -101,7 +195,7 @@ describe('readAzureIntegrationConfigs', () => { return data.map(item => new ConfigReader(item)); } - it('reads all values', () => { + it('reads all values when using a token', () => { const output = readAzureIntegrationConfigs( buildConfig([ { @@ -116,6 +210,29 @@ describe('readAzureIntegrationConfigs', () => { }); }); + it('reads all values when using a credential', () => { + const output = readAzureIntegrationConfigs( + buildConfig([ + { + host: 'dev.azure.com', + credential: { + clientId: 'id', + clientSecret: 'secret', + tenantId: 'tenant', + }, + }, + ]), + ); + expect(output).toContainEqual({ + host: 'dev.azure.com', + credential: { + clientId: 'id', + clientSecret: 'secret', + tenantId: 'tenant', + }, + }); + }); + it('adds a default entry when missing', () => { const output = readAzureIntegrationConfigs(buildConfig([])); expect(output).toEqual([ diff --git a/packages/integration/src/azure/config.ts b/packages/integration/src/azure/config.ts index 2d722d1589..6d80bee516 100644 --- a/packages/integration/src/azure/config.ts +++ b/packages/integration/src/azure/config.ts @@ -38,6 +38,71 @@ export type AzureIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The credential to use for requests. + * + * If no credential is specified anonymous access is used. + */ + credential?: AzureCredential; +}; + +/** + * Authenticate using a client secret that was generated for an App Registration. + * @public + */ +export type ClientSecret = { + /** + * The Azure Active Directory tenant + */ + tenantId: string; + /** + * The client id + */ + clientId: string; + + /** + * The client secret + */ + clientSecret: string; +}; + +/** + * Authenticate using a managed identity available at the deployment environment. + * @public + */ +export type ManagedIdentity = { + /** + * The clientId + */ + clientId: string; +}; + +/** + * Credential used to authenticate to Azure Active Directory. + * @public + */ +export type AzureCredential = ClientSecret | ManagedIdentity; +export const isServicePrincipal = ( + credential: Partial, +): credential is ClientSecret => { + const clientSecretCredential = credential as ClientSecret; + + return ( + Object.keys(credential).length === 3 && + clientSecretCredential.clientId !== undefined && + clientSecretCredential.clientSecret !== undefined && + clientSecretCredential.tenantId !== undefined + ); +}; + +export const isManagedIdentity = ( + credential: Partial, +): credential is ManagedIdentity => { + return ( + Object.keys(credential).length === 1 && + (credential as ManagedIdentity).clientId !== undefined + ); }; /** @@ -52,13 +117,43 @@ export function readAzureIntegrationConfig( const host = config.getOptionalString('host') ?? AZURE_HOST; const token = config.getOptionalString('token'); + const credential = config.getOptional('credential') + ? { + tenantId: config.getOptionalString('credential.tenantId'), + clientId: config.getOptionalString('credential.clientId'), + clientSecret: config.getOptionalString('credential.clientSecret'), + } + : undefined; + if (!isValidHost(host)) { throw new Error( `Invalid Azure integration config, '${host}' is not a valid host`, ); } - return { host, token }; + if ( + credential && + !isServicePrincipal(credential) && + !isManagedIdentity(credential) + ) { + throw new Error( + `Invalid Azure integration config, credential is not valid`, + ); + } + + 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 }; } /** diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts index ad8f2691b5..e6ef5401ac 100644 --- a/packages/integration/src/azure/core.test.ts +++ b/packages/integration/src/azure/core.test.ts @@ -19,21 +19,85 @@ import { getAzureDownloadUrl, getAzureRequestOptions, } from './core'; +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'); + +MockedClientSecretCredential.prototype.getToken.mockImplementation(() => + Promise.resolve({ token: 'fake-client-secret-token' } as AccessToken), +); +MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() => + Promise.resolve({ token: 'fake-managed-identity-token' } as AccessToken), +); describe('azure core', () => { describe('getAzureRequestOptions', () => { - it('fills in the token if necessary', () => { - expect(getAzureRequestOptions({ host: '', token: '0123456789' })).toEqual( + 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=', }), }), ); - expect(getAzureRequestOptions({ host: '' })).toEqual( + }); + + 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.not.objectContaining({ - Authorization: expect.anything(), + 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', }), }), ); diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 1d5c4c3c89..cd0345b1f8 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -15,7 +15,15 @@ */ import { AzureUrl } from './AzureUrl'; -import { AzureIntegrationConfig } from './config'; +import { + AzureIntegrationConfig, + isManagedIdentity, + isServicePrincipal, +} from './config'; +import { + ClientSecretCredential, + ManagedIdentityCredential, +} from '@azure/identity'; /** * Given a URL pointing to a file on a provider, returns a URL that is suitable @@ -59,17 +67,37 @@ export function getAzureCommitsUrl(url: string): string { * 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 function getAzureRequestOptions( +export async function getAzureRequestOptions( config: AzureIntegrationConfig, additionalHeaders?: Record, -): { headers: Record } { +): Promise<{ headers: Record }> { + const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default'; const headers: Record = additionalHeaders ? { ...additionalHeaders } : {}; - if (config.token) { + const { token, credential } = config; + if (credential) { + if (isServicePrincipal(credential)) { + const servicePrincipal = new ClientSecretCredential( + credential.tenantId, + credential.clientId, + credential.clientSecret, + ); + + const accessToken = await servicePrincipal.getToken(azureDevOpsScope); + headers.Authorization = `Bearer ${accessToken.token}`; + } else if (isManagedIdentity(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')}`; } diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index d079799c01..808043fdb0 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -19,7 +19,12 @@ export { readAzureIntegrationConfig, readAzureIntegrationConfigs, } from './config'; -export type { AzureIntegrationConfig } from './config'; +export type { + AzureIntegrationConfig, + AzureCredential, + ManagedIdentity, + ClientSecret, +} from './config'; export { getAzureCommitsUrl, getAzureDownloadUrl, diff --git a/plugins/catalog-backend-module-azure/src/lib/azure.ts b/plugins/catalog-backend-module-azure/src/lib/azure.ts index 714090d5bc..916d70a9fa 100644 --- a/plugins/catalog-backend-module-azure/src/lib/azure.ts +++ b/plugins/catalog-backend-module-azure/src/lib/azure.ts @@ -58,9 +58,9 @@ export async function codeSearch( do { const response = await fetch(searchUrl, { - ...getAzureRequestOptions(azureConfig, { + ...(await getAzureRequestOptions(azureConfig, { 'Content-Type': 'application/json', - }), + })), method: 'POST', body: JSON.stringify({ searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`, diff --git a/yarn.lock b/yarn.lock index cb0a538a85..c620a8fd5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1978,6 +1978,30 @@ __metadata: languageName: node linkType: hard +"@azure/identity@npm:^3.2.1": + version: 3.2.1 + resolution: "@azure/identity@npm:3.2.1" + dependencies: + "@azure/abort-controller": ^1.0.0 + "@azure/core-auth": ^1.3.0 + "@azure/core-client": ^1.4.0 + "@azure/core-rest-pipeline": ^1.1.0 + "@azure/core-tracing": ^1.0.0 + "@azure/core-util": ^1.0.0 + "@azure/logger": ^1.0.0 + "@azure/msal-browser": ^2.32.2 + "@azure/msal-common": ^9.0.2 + "@azure/msal-node": ^1.14.6 + events: ^3.0.0 + jws: ^4.0.0 + open: ^8.0.0 + stoppable: ^1.1.0 + tslib: ^2.2.0 + uuid: ^8.3.0 + checksum: c66405cd84c22e16031e1b6aeb09eacbabeeb9d73090f67ed770337e3cc7abe87950cf5c2f6dc7a1d354fa1676fed72fc2142916c4929699534e7ad37d7b1dd1 + languageName: node + linkType: hard + "@azure/logger@npm:^1.0.0": version: 1.0.1 resolution: "@azure/logger@npm:1.0.1" @@ -2015,16 +2039,23 @@ __metadata: languageName: node linkType: hard -"@azure/msal-browser@npm:^2.26.0": - version: 2.27.0 - resolution: "@azure/msal-browser@npm:2.27.0" +"@azure/msal-browser@npm:^2.26.0, @azure/msal-browser@npm:^2.32.2": + version: 2.37.0 + resolution: "@azure/msal-browser@npm:2.37.0" dependencies: - "@azure/msal-common": ^7.1.0 - checksum: 6cafb4d41a92f1bbb82e7128f64ccd6538ec116a2521f412108d94c4173601d31ea62febb7b22206dcae691e6a01cf7f9ce92793420599437b50a24c7e6ab851 + "@azure/msal-common": 13.0.0 + checksum: e57d04517d51db4b608da6ca127c89e5c134b42743b00e95d8f1aae011d1cc6d5c02fb4f72cd0f96636e500a3eeee7c4884cd537ac8796bbd50f0ff9ac983b4e languageName: node linkType: hard -"@azure/msal-common@npm:^7.0.0, @azure/msal-common@npm:^7.1.0": +"@azure/msal-common@npm:13.0.0": + version: 13.0.0 + resolution: "@azure/msal-common@npm:13.0.0" + checksum: 89f56f9fbf0edffa6023d46c6970dd37a9ad571b7d2952fdef41950ec2f3b4b3fd22d9841e2caaf52619ad97ab415ed22c30797cdb896f459eb665d3d97f778a + languageName: node + linkType: hard + +"@azure/msal-common@npm:^7.0.0": version: 7.1.0 resolution: "@azure/msal-common@npm:7.1.0" checksum: 761a8b9363c7b620d831aba7d7e7a66e341685e306034b027b57dc31dae11abdf86c3187c08fc8008448c845ffdcbbc42c767c75355625cf87aba25ba7e2832e @@ -2038,14 +2069,14 @@ __metadata: languageName: node linkType: hard -"@azure/msal-node@npm:^1.10.0": - version: 1.14.6 - resolution: "@azure/msal-node@npm:1.14.6" +"@azure/msal-node@npm:^1.10.0, @azure/msal-node@npm:^1.14.6": + version: 1.17.2 + resolution: "@azure/msal-node@npm:1.17.2" dependencies: - "@azure/msal-common": ^9.0.2 + "@azure/msal-common": 13.0.0 jsonwebtoken: ^9.0.0 uuid: ^8.3.0 - checksum: 7fb2085fe772474bb4c6c1e0a2467da6b2380dfa66f7c67f63d4a8774f591744dc64a6f3337d43c810ff433bc327be470ece59dcd0fa7aff4459e32beb4c738b + checksum: 5ac809dae6b02ab7de2aafb85733501ba6c3268f1d9371bc857215dad9d47e4f6e10b3e58ebda49504b375c96934761e9734acc241d3bc34d3b4cdeaabdf102e languageName: node linkType: hard @@ -4467,6 +4498,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/integration@workspace:packages/integration" dependencies: + "@azure/identity": ^3.2.1 "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^"