diff --git a/.changeset/polite-keys-confess.md b/.changeset/polite-keys-confess.md new file mode 100644 index 0000000000..007ad98641 --- /dev/null +++ b/.changeset/polite-keys-confess.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': minor +--- + +Microsoft Graph plugin can supports many more options for authenticating with the Microsoft Graph API. +Previously only ClientId/ClientSecret was supported, but now all the authentication options of `DefaultAzureCredential` from `@azure/identity` are supported. +Including Managed Identity, Client Certificate, Azure CLI and VS Code. + +If `clientId` and `clientSecret` are specified in configuration, the plugin behaves the same way as before. +If these fields are omitted, the plugin uses `DefaultAzureCredential` to automatically determine the best authentication method. +This is particularly useful for local development environments - the default configuration will try to use existing credentials from Visual Studio Code, Azure CLI and Azure PowerShell, without the user needing to configure any credentials in app-config.yaml diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md index 8e17bb2cc1..37ef0634fc 100644 --- a/docs/integrations/azure/org.md +++ b/docs/integrations/azure/org.md @@ -3,12 +3,239 @@ id: org title: Microsoft Azure Active Directory Organizational Data sidebar_label: Org Data # prettier-ignore -description: Importing users and groups from a Microsoft Azure Active Directory into Backstage +description: Importing users and groups from Microsoft Azure Active Directory into Backstage --- The Backstage catalog can be set up to ingest organizational data - users and teams - directly from a tenant in Microsoft Azure Active Directory via the Microsoft Graph API. -More details on this are available in the -[README of the `@backstage/plugin-catalog-backend-module-msgraph` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-msgraph/README.md). +## Installation + +The package is not installed by default, therefore you have to add `@backstage/plugin-catalog-backend-module-msgraph` to your backend package. + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-msgraph +``` + +Next add the basic configuration to `app-config.yaml` + +```yaml +catalog: + providers: + microsoftGraphOrg: + default: + tenantId: ${AZURE_TENANT_ID} + user: + filter: accountEnabled eq true and userType eq 'member' + group: + filter: > + securityEnabled eq false + and mailEnabled eq true + and groupTypes/any(c:c+eq+'Unified') +``` + +Finally, register the plugin in `catalog.ts`. +For large organizations, this plugin can take a long time, so be careful setting low frequency / timeouts. + +```diff + // packages/backend/src/plugins/catalog.ts ++import { MicrosoftGraphOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-msgraph'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { + const builder = await CatalogBuilder.create(env); + ++ builder.addEntityProvider( ++ MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { ++ logger: env.logger, ++ schedule: env.scheduler.createScheduledTaskRunner({ ++ frequency: { hours: 1 }, ++ timeout: { minutes: 50 }, ++ initialDelay: { seconds: 15} ++ }), ++ }), ++ ); +``` + +## Authenticating with Microsoft Graph + +### Local Development + +For a local dev environment, it's recommended you have the Azure CLI or Azure PowerShell installed, and are logged in to those. +Alternatively you can use VSCode with the Azure extension if you install `@azure/identity-vscode`. +When these are set up, the plugin will authenticate with the Microsoft Graph API without you needing to configure any credentials, or granting any special permissions. +If you can't do this, you'll have to create an App Registration. + +## App Registration + +If none of the other authentication methods work, you can create an app registration in the azure portal. +By default the graph plugin requires the following Application permissions (not Delegated) for Microsoft Graph: + +- `GroupMember.Read.All` +- `User.Read.All` + +If your organization required Admin Consent for these permissions, that will need to be granted. + +When authenticating with a ClientId/ClientSecret, you can either set the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_SECRET` environment variables, or specify the values in configuration + +```yaml +microsoftGraphOrg: + default: + ##... + clientId: 9ef1aac6-b454-4e69-9cf5-7199df049281 + clientSecret: REDACTED +``` + +To authenticate with a certificate rather than a client secret, you can set the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_CERTIFICATE_PATH` environments + +### Managed Identity + +If deploying to resources that supports Managed Identity, and has identities configured (e.g. Azure App Services, Azure Container Apps), Managed Identity should be picked up without any additional configuration. +If your app has multiple managed identities, you may need to set the `AZURE_CLIENT_ID` environment variable to tell Azure Identity which identity to use. + +You will need to grant the same permissions to your identity as described for App Registrations above. + +## Filtering imported Users and Groups + +By default, the plugin will import all users and groups from your directory. +This can be customized through filters and search queries. + +### Groups + +A smaller set of groups can be obtained by configuring a search query or a filter. +If both `filter` and `search` are provided, then groups must match both to be ingested. + +```yaml +microsoftGraphOrg: + providerId: + group: + filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') + search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' +``` + +In addition to these groups, one additional group will be created for your organization. +All imported groups will be a child of this group. + +### Users + +There are two modes for importing users - You can import all user objects matching a `filter`. + +```yaml +microsoftGraphOrg: + providerId: + user: + filter: accountEnabled eq true and userType eq 'member' +``` + +Alternatively you can import users that are members of specific groups. +For each group matching the `search` and `filter` query, each group member will be imported. +Only direct group members will be imported, not transient users. + +```yaml +microsoftGraphOrg: + providerId: + userGroupMember: + filter: "displayName eq 'Backstage Users'" + search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' +``` + +## Customizing Transformation + +Ingested entities can be customized by providing custom transformers. +These can be used to completely replace the built in logic, or used to tweak it by using the default transformers (`defaultGroupTransformer`, `defaultUserTransformer` and `defaultOrganizationTransformer` +Entities can also be excluded from backstage by returning `undefined`. + +These Transformers are be registered when configuring `MicrosoftGraphOrgEntityProvider` + +```diff + builder.addEntityProvider( + MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { + // ... ++ groupTransformer: myGroupTransformer, ++ userTransformer: myUserTransformer, ++ organizationTransformer: myOrganizationTransformer, + }), + ); +``` + +When using custom transformers, you may want to customize the data returned. +Several configuration options can be provided to tweak the Microsoft Graph query to get the data you need + +```yaml +microsoftGraphOrg: + providerId: + user: + expand: manager + group: + expand: member + select: ['id', 'displayName', 'description'] +``` + +The following provides an example of each kind of transformer + +```ts +import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; +import { + defaultGroupTransformer, + defaultUserTransformer, + defaultOrganizationTransformer, +} from '@backstage/plugin-catalog-backend-module-msgraph'; +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; + +// This group transformer completely replaces the built in logic with custom logic. +export async function myGroupTransformer( + group: MicrosoftGraph.Group, + groupPhoto?: string, +): Promise { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: group.id!, + annotations: {}, + }, + spec: { + type: 'aad', + children: [], + }, + }; +} + +// This user transformer makes use of the built in logic, but also sets the description field +export async function myUserTransformer( + graphUser: MicrosoftGraph.User, + userPhoto?: string, +): Promise { + const backstageUser = await defaultUserTransformer(graphUser, userPhoto); + + if (backstageUser) { + backstageUser.metadata.description = 'Loaded from Azure Active Directory'; + } + + return backstageUser; +} + +// Example organization transformer that removes the organization group completely +export async function myOrganizationTransformer( + graphOrganization: MicrosoftGraph.Organization, +): Promise { + return undefined; +} +``` + +## Troubleshooting + +### Authentication / Token Errors + +See [Troubleshooting Azure Identity Authentication Issues](https://aka.ms/azsdk/js/identity/troubleshoot) + +### Error while reading users from Microsoft Graph: Authorization_RequestDenied - Insufficient privileges to complete the operation. + +- Make sure you've granted all the required permissions to your application registration or managed identity +- Make sure the permissions are `Application` permissions rather than `Delegated` +- If your organization has configured "Admin consent" to be required, make sure this has been granted for your application permissions +- If your group queries are returning Microsoft Teams groups, you may need to grant addition permissions (e.g. `Team.ReadBasic.All`, `TeamMember.Read.All`) +- If you've added additional `select` or `expand` fields, those may need additional permissions granted diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index 6132d5abc6..23166a755d 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -6,13 +6,18 @@ This provider is useful if you want to import users and groups from Azure Active ## Getting Started -1. Create or use an existing App registration in the [Microsoft Azure Portal](https://portal.azure.com/). - The App registration requires at least the API permissions `Group.Read.All`, - `GroupMember.Read.All`, `User.Read` and `User.Read.All` for Microsoft Graph - (if you still run into errors about insufficient privileges, add - `Team.ReadBasic.All` and `TeamMember.Read.All` too). +1. Choose your authentication method - all methods supported by [DefaultAzureCredential](https://docs.microsoft.com/en-us/javascript/api/overview/azure/identity-readme?view=azure-node-latest#defaultazurecredential) -2. Configure the entity provider: + - For local dev, use Azure CLI, Azure PowerShell or Visual Studio Code for authentication + - If your infrastructure supports Managed Identity, use that + - Otherwise use an App Registration + +1. If using Managed Identity or App Registration for authentication, grant the following application permissions (not delegated) + + - `GroupMember.Read.All` + - `User.Read.All` + +1. Configure the entity provider: ```yaml # app-config.yaml @@ -24,11 +29,13 @@ catalog: authority: https://login.microsoftonline.com # If you don't know you tenantId, you can use Microsoft Graph Explorer # to query it - tenantId: ${MICROSOFT_GRAPH_TENANT_ID} + tenantId: ${AZURE_TENANT_ID} + # Optional ClientId and ClientSecret if you don't want to use `DefaultAzureCredential` + # for authentication # Client Id and Secret can be created under Certificates & secrets in # the App registration in the Microsoft Azure Portal. - clientId: ${MICROSOFT_GRAPH_CLIENT_ID} - clientSecret: ${MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN} + clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} # Optional mode for querying which defaults to "basic". # By default, the Microsoft Graph API only provides the basic feature set # for querying. Certain features are limited to advanced querying capabilities. @@ -108,8 +115,9 @@ yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-msgraph + MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ -+ frequency: { minutes: 5 }, -+ timeout: { minutes: 3 }, ++ frequency: { hours: 1 }, ++ timeout: { minutes: 50 }, ++ initialDelay: { seconds: 15} + }), + }), + ); diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index a4083b1d9e..8f773e1b90 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -12,9 +12,9 @@ import { GroupEntity } from '@backstage/catalog-model'; import { LocationSpec } from '@backstage/plugin-catalog-backend'; import { Logger } from 'winston'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; -import * as msal from '@azure/msal-node'; import { Response as Response_2 } from 'node-fetch'; import { TaskRunner } from '@backstage/backend-tasks'; +import { TokenCredential } from '@azure/identity'; import { UserEntity } from '@backstage/catalog-model'; // @public @@ -65,7 +65,7 @@ export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id'; // @public export class MicrosoftGraphClient { - constructor(baseUrl: string, pca: msal.ConfidentialClientApplication); + constructor(baseUrl: string, tokenCredential: TokenCredential); static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient; getGroupMembers(groupId: string): AsyncIterable; // (undocumented) @@ -190,8 +190,8 @@ export type MicrosoftGraphProviderConfig = { target: string; authority?: string; tenantId: string; - clientId: string; - clientSecret: string; + clientId?: string; + clientSecret?: string; userFilter?: string; userExpand?: string; userGroupMemberFilter?: string; diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index 564ccbb10a..8748df5974 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -50,13 +50,13 @@ export interface Config { /** * The OAuth client ID to use for authenticating requests. */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. * * @visibility secret */ - clientSecret: string; + clientSecret?: string; // TODO: Consider not making these config options and pass them in the // constructor instead. They are probably not environment specific, so @@ -130,13 +130,13 @@ export interface Config { /** * The OAuth client ID to use for authenticating requests. */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. * * @visibility secret */ - clientSecret: string; + clientSecret?: string; user?: { /** diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index f4d22e3c86..8def55c0a3 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -33,7 +33,7 @@ "start": "backstage-cli package start" }, "dependencies": { - "@azure/msal-node": "^1.1.0", + "@azure/identity": "^2.1.0", "@backstage/backend-tasks": "^0.3.3-next.3", "@backstage/catalog-model": "^1.1.0-next.3", "@backstage/config": "^1.0.1", @@ -43,9 +43,9 @@ "lodash": "^4.17.21", "node-fetch": "^2.6.7", "p-limit": "^3.0.2", + "qs": "^6.9.4", "uuid": "^8.0.0", - "winston": "^3.2.1", - "qs": "^6.9.4" + "winston": "^3.2.1" }, "devDependencies": { "@backstage/backend-common": "^0.14.1-next.3", diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts index 5ff4fa4e0e..94502b9e7d 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts @@ -14,30 +14,26 @@ * limitations under the License. */ -import * as msal from '@azure/msal-node'; +import { TokenCredential } from '@azure/identity'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { MicrosoftGraphClient } from './client'; describe('MicrosoftGraphClient', () => { - const confidentialClientApplication: jest.Mocked = - { - acquireTokenByClientCredential: jest.fn(), - } as any; + const tokenCredential: jest.Mocked = { + getToken: jest.fn(), + } as any; let client: MicrosoftGraphClient; const worker = setupServer(); setupRequestMockHandlers(worker); beforeEach(() => { - confidentialClientApplication.acquireTokenByClientCredential.mockResolvedValue( - { token: 'ACCESS_TOKEN' } as any, - ); - client = new MicrosoftGraphClient( - 'https://example.com', - confidentialClientApplication, - ); + tokenCredential.getToken.mockResolvedValue({ + token: 'ACCESS_TOKEN', + } as any); + client = new MicrosoftGraphClient('https://example.com', tokenCredential); }); afterEach(() => { @@ -55,12 +51,10 @@ describe('MicrosoftGraphClient', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ value: 'example' }); - expect( - confidentialClientApplication.acquireTokenByClientCredential, - ).toBeCalledTimes(1); - expect( - confidentialClientApplication.acquireTokenByClientCredential, - ).toBeCalledWith({ scopes: ['https://graph.microsoft.com/.default'] }); + expect(tokenCredential.getToken).toBeCalledTimes(1); + expect(tokenCredential.getToken).toBeCalledWith( + 'https://graph.microsoft.com/.default', + ); }); it('should perform simple api request', async () => { diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts index 20e791c5d5..26da0cb64b 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import * as msal from '@azure/msal-node'; +import { + TokenCredential, + DefaultAzureCredential, + ClientSecretCredential, +} from '@azure/identity'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import fetch, { Response } from 'node-fetch'; import qs from 'qs'; @@ -77,25 +81,32 @@ export class MicrosoftGraphClient { * @param config - Configuration for Interacting with Graph API */ static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient { - const clientConfig: msal.Configuration = { - auth: { - clientId: config.clientId, - clientSecret: config.clientSecret, - authority: `${config.authority}/${config.tenantId}`, - }, + const options = { + authorityHost: config.authority, + tenantId: config.tenantId, }; - const pca = new msal.ConfidentialClientApplication(clientConfig); - return new MicrosoftGraphClient(config.target, pca); + + const credential = + config.clientId && config.clientSecret + ? new ClientSecretCredential( + config.tenantId, + config.clientId, + config.clientSecret, + options, + ) + : new DefaultAzureCredential(options); + + return new MicrosoftGraphClient(config.target, credential); } /** * @param baseUrl - baseUrl of Graph API {@link MicrosoftGraphProviderConfig.target} - * @param pca - instance of `msal.ConfidentialClientApplication` that is used to acquire token for Graph API calls + * @param tokenCredential - instance of `TokenCredential` that is used to acquire token for Graph API calls * */ constructor( private readonly baseUrl: string, - private readonly pca: msal.ConfidentialClientApplication, + private readonly tokenCredential: TokenCredential, ) {} /** @@ -202,18 +213,18 @@ export class MicrosoftGraphClient { headers?: Record, ): Promise { // Make sure that we always have a valid access token (might be cached) - const token = await this.pca.acquireTokenByClientCredential({ - scopes: ['https://graph.microsoft.com/.default'], - }); + const token = await this.tokenCredential.getToken( + 'https://graph.microsoft.com/.default', + ); if (!token) { - throw new Error('Error while requesting token for Microsoft Graph'); + throw new Error('Failed to obtain token from Azure Identity'); } return await fetch(url, { headers: { ...headers, - Authorization: `Bearer ${token.accessToken}`, + Authorization: `Bearer ${token.token}`, }, }); } diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index 8239090249..8e2866df65 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -25,8 +25,6 @@ describe('readMicrosoftGraphConfig', () => { id: 'target', target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', }, ], }; @@ -36,11 +34,6 @@ describe('readMicrosoftGraphConfig', () => { id: 'target', target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.microsoftonline.com', - userFilter: undefined, - groupFilter: undefined, }, ]; expect(actual).toEqual(expected); @@ -72,7 +65,7 @@ describe('readMicrosoftGraphConfig', () => { tenantId: 'tenantId', clientId: 'clientId', clientSecret: 'clientSecret', - authority: 'https://login.example.com', + authority: 'https://login.example.com/', userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', @@ -87,12 +80,7 @@ describe('readMicrosoftGraphConfig', () => { const config = { providers: [ { - id: 'target', - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', userFilter: 'accountEnabled eq true', userGroupMemberFilter: 'any', }, @@ -105,12 +93,7 @@ describe('readMicrosoftGraphConfig', () => { const config = { providers: [ { - id: 'target', - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', userFilter: 'accountEnabled eq true', userGroupMemberSearch: 'any', }, @@ -118,6 +101,30 @@ describe('readMicrosoftGraphConfig', () => { }; expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); }); + + it('should fail if clientId is set without clientSecret', () => { + const config = { + providers: [ + { + tenantId: 'tenantId', + clientId: 'clientId', + }, + ], + }; + expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); + }); + + it('should fail if clientSecret is set without clientId', () => { + const config = { + providers: [ + { + tenantId: 'tenantId', + clientSecret: 'clientId', + }, + ], + }; + expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); + }); }); describe('readProviderConfigs', () => { @@ -127,10 +134,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', }, }, }, @@ -140,11 +144,8 @@ describe('readProviderConfigs', () => { const expected = [ { id: 'customProviderId', - target: 'target', + target: 'https://graph.microsoft.com/v1.0', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.microsoftonline.com', }, ]; expect(actual).toEqual(expected); @@ -183,7 +184,7 @@ describe('readProviderConfigs', () => { tenantId: 'tenantId', clientId: 'clientId', clientSecret: 'clientSecret', - authority: 'https://login.example.com', + authority: 'https://login.example.com/', userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', @@ -200,11 +201,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', user: { filter: 'accountEnabled eq true', }, @@ -225,11 +222,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', user: { filter: 'accountEnabled eq true', }, @@ -243,4 +236,36 @@ describe('readProviderConfigs', () => { }; expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); }); + + it('should fail if clientId is set without clientSecret', () => { + const config = { + catalog: { + providers: { + microsoftGraphOrg: { + customProviderId: { + tenantId: 'tenantId', + clientId: 'id', + }, + }, + }, + }, + }; + expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); + }); + + it('should fail if clientSecret is set without clientId', () => { + const config = { + catalog: { + providers: { + microsoftGraphOrg: { + customProviderId: { + tenantId: 'tenantId', + clientSecret: 'clientSecret', + }, + }, + }, + }, + }; + expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); + }); }); diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 30e3a9a4d2..f7675acf42 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -17,7 +17,6 @@ import { Config } from '@backstage/config'; import { trimEnd } from 'lodash'; -const DEFAULT_AUTHORITY = 'https://login.microsoftonline.com'; const DEFAULT_PROVIDER_ID = 'default'; const DEFAULT_TARGET = 'https://graph.microsoft.com/v1.0'; @@ -49,12 +48,14 @@ export type MicrosoftGraphProviderConfig = { tenantId: string; /** * The OAuth client ID to use for authenticating requests. + * If specified, ClientSecret must also be specified */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. + * If specified, ClientId must also be specified */ - clientSecret: string; + clientSecret?: string; /** * The filter to apply to extract users. * @@ -131,14 +132,15 @@ export function readMicrosoftGraphConfig( const providerConfigs = config.getOptionalConfigArray('providers') ?? []; for (const providerConfig of providerConfigs) { - const target = trimEnd(providerConfig.getString('target'), '/'); + const target = trimEnd( + providerConfig.getOptionalString('target') ?? DEFAULT_TARGET, + '/', + ); + const authority = providerConfig.getOptionalString('authority'); - const authority = providerConfig.getOptionalString('authority') - ? trimEnd(providerConfig.getOptionalString('authority'), '/') - : DEFAULT_AUTHORITY; const tenantId = providerConfig.getString('tenantId'); - const clientId = providerConfig.getString('clientId'); - const clientSecret = providerConfig.getString('clientSecret'); + const clientId = providerConfig.getOptionalString('clientId'); + const clientSecret = providerConfig.getOptionalString('clientSecret'); const userExpand = providerConfig.getOptionalString('userExpand'); const userFilter = providerConfig.getOptionalString('userFilter'); @@ -173,6 +175,18 @@ export function readMicrosoftGraphConfig( throw new Error(`queryMode must be one of: basic, advanced`); } + if (clientId && !clientSecret) { + throw new Error( + `clientSecret must be provided when clientId is defined.`, + ); + } + + if (clientSecret && !clientId) { + throw new Error( + `clientId must be provided when clientSecret is defined.`, + ); + } + providers.push({ id: target, target, @@ -225,14 +239,11 @@ export function readProviderConfig( config.getOptionalString('target') ?? DEFAULT_TARGET, '/', ); - const authority = trimEnd( - config.getOptionalString('authority') ?? DEFAULT_AUTHORITY, - '/', - ); + const authority = config.getOptionalString('authority'); - const clientId = config.getString('clientId'); - const clientSecret = config.getString('clientSecret'); const tenantId = config.getString('tenantId'); + const clientId = config.getOptionalString('clientId'); + const clientSecret = config.getOptionalString('clientSecret'); const userExpand = config.getOptionalString('user.expand'); const userFilter = config.getOptionalString('user.filter'); @@ -260,6 +271,14 @@ export function readProviderConfig( ); } + if (clientId && !clientSecret) { + throw new Error(`clientSecret must be provided when clientId is defined.`); + } + + if (clientSecret && !clientId) { + throw new Error(`clientId must be provided when clientSecret is defined.`); + } + return { id, target, diff --git a/yarn.lock b/yarn.lock index d53cd4a62b..416d58ae9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -320,7 +320,7 @@ dependencies: tslib "^2.2.0" -"@azure/identity@^2.0.1", "@azure/identity@^2.0.4": +"@azure/identity@^2.0.1", "@azure/identity@^2.0.4", "@azure/identity@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@azure/identity/-/identity-2.1.0.tgz#89f0bfc1d1264dfd3d0cb19837c33a9c6706d548" integrity sha512-BPDz1sK7Ul9t0l9YKLEa8PHqWU4iCfhGJ+ELJl6c8CP3TpJt2urNCbm0ZHsthmxRsYoMPbz2Dvzj30zXZVmAFw== @@ -361,7 +361,7 @@ resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-7.1.0.tgz#b77dbf9ae581f1ed254f81d56422e3cdd6664b32" integrity sha512-WyfqE5mY/rggjqvq0Q5DxLnA33KSb0vfsUjxa95rycFknI03L5GPYI4HTU9D+g0PL5TtsQGnV3xzAGq9BFCVJQ== -"@azure/msal-node@^1.1.0", "@azure/msal-node@^1.10.0": +"@azure/msal-node@^1.10.0": version "1.11.0" resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.11.0.tgz#d8bd3f15c1f05bf806ba6f9479c48c2eddd6a98d" integrity sha512-KW/XEexfCrPzdYbjY7NVmhq9okZT3Jvck55CGXpz9W5asxeq3EtrP45p+ZXtQVEfko0YJdolpCNqWUyXvanWZg==