feat(azure): support service principals and managed identities

Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
Sander Aernouts
2023-05-12 16:08:47 +02:00
parent 6325e9cd11
commit c7f848bcea
12 changed files with 436 additions and 37 deletions
+9
View File
@@ -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.
+35 -3
View File
@@ -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
@@ -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
+18 -2
View File
@@ -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<string, string>,
): {
): Promise<{
headers: Record<string, string>;
};
}>;
// @public
export function getBitbucketCloudDefaultBranch(
@@ -535,6 +546,11 @@ export interface IntegrationsByType {
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
}
// @public
export type ManagedIdentity = {
clientId: string;
};
// @public
export function parseGerritGitilesUrl(
config: GerritIntegrationConfig,
+1
View File
@@ -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",
+121 -4
View File
@@ -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([
+96 -1
View File
@@ -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<AzureCredential>,
): 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<AzureCredential>,
): 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<AzureCredential>('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 };
}
/**
+69 -5
View File
@@ -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',
}),
}),
);
+32 -4
View File
@@ -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<string, string>,
): { headers: Record<string, string> } {
): Promise<{ headers: Record<string, string> }> {
const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
const headers: Record<string, string> = 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')}`;
}
+6 -1
View File
@@ -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,
@@ -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 || '*'}`,
+43 -11
View File
@@ -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:^"