Merge pull request #12490 from afscrome/aadazurecli
Use Azure Identity to Authenticate with Microsoft Graph API
This commit is contained in:
@@ -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}
|
||||
+ }),
|
||||
+ }),
|
||||
+ );
|
||||
|
||||
@@ -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<GroupMember>;
|
||||
// (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;
|
||||
|
||||
+4
-4
@@ -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?: {
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<msal.ConfidentialClientApplication> =
|
||||
{
|
||||
acquireTokenByClientCredential: jest.fn(),
|
||||
} as any;
|
||||
const tokenCredential: jest.Mocked<TokenCredential> = {
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, string>,
|
||||
): Promise<Response> {
|
||||
// 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user