feat(azure): support managed identity federated credentials
Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Added support for federated credentials using managed identities in the Azure DevOps integration. Federated credentials are only available for Azure DevOps organizations that have been configured to use Entra ID for authentication.
|
||||
|
||||
```diff
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
+ - clientId: ${APP_REGISTRATION_CLIENT_ID}
|
||||
+ managedIdentityClientId: system-assigned
|
||||
+ tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
This also adds support for automatically using the system-assigned managed identity of an Azure resource by specifying `system-assigned` as the client ID of the managed identity.
|
||||
|
||||
```diff
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- - clientId: ${AZURE_CLIENT_ID}
|
||||
+ - clientId: system-assigned
|
||||
```
|
||||
@@ -13,7 +13,17 @@ or registered with the
|
||||
[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import)
|
||||
plugin.
|
||||
|
||||
Using a service principal:
|
||||
## Authentication
|
||||
|
||||
The Azure integration supports several methods to authenticate against Azure DevOps. The following sections describe how to configure the integration for each authentication method.
|
||||
|
||||
It is also possible to configure separate authentication methods for different Azure DevOps organizations. This is useful if you have multiple organizations and want to use (or have to) different credentials for each organization.
|
||||
|
||||
### Using a service principal with a client secret
|
||||
|
||||
A service principal is an Entra ID identity that can be used to authenticate against Azure DevOps. The service principal is created in Entra ID and has a client ID and client secret (akin to a username and password).
|
||||
|
||||
The following configuration shows how to use a service principal to authenticate against Azure DevOps:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
@@ -25,7 +35,29 @@ integrations:
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
Using a managed identity:
|
||||
See the Azure DevOps documentation on how to grant access to the [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity).
|
||||
|
||||
#### Using a system-assigned managed identity
|
||||
|
||||
A system-assigned [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is an Entra ID identity that is tied to a specific Azure resource and managed by Azure. In contrast to a user-assigned managed identity, a system-assigned managed identity shares the lifecycle of the resource to which it is assigned and Azure guarantees that the identity can only be used by the specific resource.
|
||||
|
||||
The following configuration shows how to use a system-assigned managed identity to authenticate against Azure DevOps:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- clientId: system-assigned
|
||||
```
|
||||
|
||||
See the Azure DevOps documentation on how to grant access to the [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity).
|
||||
|
||||
#### Using a user-assigned managed identity
|
||||
|
||||
A user-assigned [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is an Entra ID identity that is created as a standalone resource by the user and assigned to one or more Azure resources. This allows you to use the same managed identity across multiple resources.
|
||||
|
||||
The following configuration shows how to use a user-assigned managed identity to authenticate against Azure DevOps:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
@@ -35,7 +67,13 @@ integrations:
|
||||
- clientId: ${AZURE_CLIENT_ID}
|
||||
```
|
||||
|
||||
Using a personal access token (PAT):
|
||||
See the Azure DevOps documentation on how to grant access to the [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity).
|
||||
|
||||
### Using a personal access token (PAT)
|
||||
|
||||
A personal access token (PAT) is a token you generate with a specific scope and expiration date. It allows Backstage to authenticate against Azure DevOps on your behalf.
|
||||
|
||||
The following configuration shows how to use a personal access token to authenticate against Azure DevOps:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
@@ -45,6 +83,55 @@ integrations:
|
||||
- personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
See the Azure DevOps documentation on how to create a [personal access token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate)
|
||||
|
||||
### Using a service principal with a managed identity to generate the client assertion
|
||||
|
||||
Using a managed identity to generate a client assertion is an advanced scenario. It requires you to setup a federated credential for the app registration in Azure Entra ID.
|
||||
|
||||
It is most useful when you want to [authenticate against an Azure DevOps organization in a different tenant](#authenticate-against-an-azure-devops-organization-in-a-different-tenant) than the managed identity itself. Otherwise [a regular managed identity](#using-a-system-assigned-managed-identity) is probably a more suitable choice.
|
||||
|
||||
#### Add a federated credential
|
||||
|
||||
To be able to use a managed identity to generate a client assertion, you need to create a federated credential in Azure Entra ID. Follow these steps:
|
||||
|
||||
1. Create an app registration in Entra ID (or use an existing one).
|
||||
2. Navigate to the "Certificates & secrets" tab for your app registration.
|
||||
3. Add a new federated credential using the "Customer managed keys" scenario.
|
||||
4. Select the managed identity you want to use to generate the client assertion.
|
||||
5. Enter the name and description.
|
||||
6. Click "Add".
|
||||
|
||||
You can now add the required configuration to the Azure DevOps integration in Backstage. The `${APP_REGISTRATION_CLIENT_ID}` is the client ID of the app registration in Entra ID where you added the federated credential.
|
||||
|
||||
#### Using a system-assigned managed identity to generate the client assertion
|
||||
|
||||
This is the most secure option because Azure guarantees that the identity can only be used by the specific resource, whereas a user-assigned managed identity can be assigned to any resource in the same tenant.
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- clientId: ${APP_REGISTRATION_CLIENT_ID}
|
||||
managedIdentityClientId: system-assigned
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
#### Using a user-assigned managed identity to generate the client assertion
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- clientId: ${APP_REGISTRATION_CLIENT_ID}
|
||||
managedIdentityClientId: ${MANAGED_IDENTITY_CLIENT_ID}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
### Authenticating against multiple Azure DevOps organizations
|
||||
|
||||
You can use specific credentials for different Azure DevOps organizations by specifying the `organizations` field on the credential:
|
||||
|
||||
```yaml
|
||||
@@ -68,27 +155,76 @@ integrations:
|
||||
|
||||
If you do not specify the `organizations` field the credential will be used for all organizations for which no other credential is configured.
|
||||
|
||||
### Authenticate against an Azure DevOps organization in a different tenant
|
||||
|
||||
If you need to authenticate against an Azure DevOps organization in a different tenant than the service principal, you have to either:
|
||||
|
||||
- [Create a multi-tenant application in Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/single-and-multi-tenant-apps).
|
||||
- [Convert the existing application to a multi-tenant application](https://learn.microsoft.com/en-gb/entra/identity-platform/howto-convert-app-to-be-multi-tenant#update-registration-to-be-multitenant).
|
||||
|
||||
:::note 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
|
||||
[personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate),
|
||||
a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity),
|
||||
or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity)
|
||||
Make sure that your application requests at least one Graph API permission. This is required to be able to install the application in another tenant. The least privileged permission you can request is the [`email` permission](https://learn.microsoft.com/en-us/graph/permissions-reference#email) with type `Delegated`. This allows the application to read the e-mail address of the signed-in user, but without the [`openid` permission](https://learn.microsoft.com/en-us/graph/permissions-reference#openid) users cannot actually sign in.
|
||||
|
||||
:::
|
||||
|
||||
After you have done that, an admin from the other tenant has to install your application by providing admin consent for the requested permissions. This can be done by visiting the following URL:
|
||||
|
||||
```plaintext
|
||||
https://login.microsoftonline.com/<other-tenant-id>/oauth2/authorize?client_id=<client-id>&response_type=code&redirect_uri=<redirect-uri>
|
||||
```
|
||||
|
||||
The `<other-tenant-id>` is the tenant ID of the other tenant, `<client-id>` is the client ID of the application (in your tenant), and `<redirect-uri>` is the redirect URI configured for the application. The redirect URI must be a valid URI in the application registration, but you can use any valid URI for this purpose, for example `https://backstage.io`.
|
||||
|
||||
After the admin has consented to the application, an Enterprise Application, also called a Service Principal, will be created in the other tenant with the same client ID as the app registration in the original tenant. You can now grant the service principal access to the Azure DevOps organization in the other tenant. To authenticate against the Azure DevOps organization in the other tenant, you can use the same service principal as before, but with the tenant ID of the other tenant:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: dev.azure.com
|
||||
credentials:
|
||||
- clientId: ${APP_REGISTRATION_CLIENT_ID}
|
||||
managedIdentityClientId: system-assigned
|
||||
tenantId: ${OTHER_TENANT_ID}
|
||||
```
|
||||
|
||||
Where `${APP_REGISTRATION_CLIENT_ID}` is the client ID of the multi-tenant app registration in you created in your own tenant, and `${OTHER_TENANT_ID}` is the tenant ID of the other tenant where you .
|
||||
|
||||
:::note Note
|
||||
|
||||
The example above uses a [system-assigned managed identity to generate the client assertion](#using-a-system-assigned-managed-identity-to-generate-the-client-assertion). You can also use a [user-assigned managed identity to generate the client assertion](#using-a-user-assigned-managed-identity-to-generate-the-client-assertion) or a client secret to authenticate for the application.
|
||||
|
||||
However a system-assigned managed identity is the most secure option because:
|
||||
|
||||
- Azure guarantees that the identity can only be used by the specific resource, whereas a user-assigned managed identity can be used by any resource.
|
||||
- There is no need to manage the underlying secrets, Azure takes care of that for you.
|
||||
|
||||
:::
|
||||
|
||||
## Configuration schema
|
||||
|
||||
The configuration is a structure with these elements:
|
||||
|
||||
- `credentials`: (optional): A service principal, managed identity, or personal access token
|
||||
- `credentials`: (optional): must be one of the following:
|
||||
- A service principal using a client secret
|
||||
- A service principal using a managed identity client assertion
|
||||
- A managed identity
|
||||
- A personal access token
|
||||
|
||||
The `credentials` element is a structure with these elements:
|
||||
The `credentials` element is an array where each entry is a structure with exactly these of elements:
|
||||
|
||||
- `organizations`: (optional): A list of organizations for which this credential should be used. If not specified the credential will be used for all organizations for which no other credential is configured.
|
||||
- `clientId`: The client ID of the service principal or managed identity (required for service principal and managed identities)
|
||||
- `clientSecret`: The client secret of the service principal (required for service principal)
|
||||
- `tenantId`: The tenant ID of the service principal (required for service principal)
|
||||
- `personalAccessToken`: The personal access token (required for personal access token)
|
||||
- For a service principal with client secret:
|
||||
- `clientId`: The client ID of the service principal
|
||||
- `clientSecret`: The client secret of the service principal
|
||||
- `tenantId`: The tenant ID of the service principal
|
||||
- For a service principal with managed identity client assertion:
|
||||
- `clientId`: The client ID of the service principal
|
||||
- `managedIdentityClientId`: the client ID of the managed identity used to generate the client assertion token. Use `system-assigned` for system-assigned managed identities or the client ID of a user-assigned managed identity.
|
||||
- `tenantId`: The tenant ID of the service principal
|
||||
- For managed identity:
|
||||
- `clientId`: the client ID of the managed identity used to generate the client assertion token.
|
||||
- For personal access token:
|
||||
- `personalAccessToken`: The personal access token
|
||||
|
||||
:::note Note
|
||||
|
||||
@@ -96,5 +232,6 @@ The `credentials` element is a structure with these elements:
|
||||
- You can only use a service principal or managed identity for Microsoft Entra ID (formerly Azure Active Directory) backed Azure DevOps organizations
|
||||
- You can only specify one credential per host without any organizations specified
|
||||
- The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API
|
||||
- The managed identity used to generate the client assertion must be in the same Entra ID tenant as the app registration.
|
||||
|
||||
:::
|
||||
|
||||
Vendored
+1
@@ -61,6 +61,7 @@ export interface Config {
|
||||
clientSecret?: string;
|
||||
tenantId?: string;
|
||||
personalAccessToken?: string;
|
||||
managedIdentityClientId?: string;
|
||||
}[];
|
||||
/**
|
||||
* PGP signing key for signing commits.
|
||||
|
||||
@@ -137,6 +137,7 @@ export interface AzureCredentialsManager {
|
||||
// @public
|
||||
export type AzureDevOpsCredential =
|
||||
| AzureClientSecretCredential
|
||||
| AzureManagedIdentityClientAssertionCredential
|
||||
| AzureManagedIdentityCredential
|
||||
| PersonalAccessTokenCredential;
|
||||
|
||||
@@ -144,11 +145,13 @@ export type AzureDevOpsCredential =
|
||||
export type AzureDevOpsCredentialKind =
|
||||
| 'PersonalAccessToken'
|
||||
| 'ClientSecret'
|
||||
| 'ManagedIdentity';
|
||||
| 'ManagedIdentity'
|
||||
| 'ManagedIdentityClientAssertion';
|
||||
|
||||
// @public
|
||||
export type AzureDevOpsCredentialLike = Omit<
|
||||
Partial<AzureClientSecretCredential> &
|
||||
Partial<AzureManagedIdentityClientAssertionCredential> &
|
||||
Partial<AzureManagedIdentityCredential> &
|
||||
Partial<PersonalAccessTokenCredential>,
|
||||
'kind'
|
||||
@@ -204,10 +207,19 @@ export type AzureIntegrationConfig = {
|
||||
commitSigningKey?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureManagedIdentityClientAssertionCredential =
|
||||
AzureCredentialBase & {
|
||||
kind: 'ManagedIdentityClientAssertion';
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
managedIdentityClientId: 'system-assigned' | string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureManagedIdentityCredential = AzureCredentialBase & {
|
||||
kind: 'ManagedIdentity';
|
||||
clientId: string;
|
||||
clientId: 'system-assigned' | string;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
import {
|
||||
AccessToken,
|
||||
ClientAssertionCredential,
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
@@ -24,6 +25,11 @@ const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
|
||||
typeof ClientSecretCredential
|
||||
>;
|
||||
|
||||
const MockedClientAssertionCredential =
|
||||
ClientAssertionCredential as jest.MockedClass<
|
||||
typeof ClientAssertionCredential
|
||||
>;
|
||||
|
||||
const MockedManagedIdentityCredential =
|
||||
ManagedIdentityCredential as jest.MockedClass<
|
||||
typeof ManagedIdentityCredential
|
||||
@@ -46,6 +52,13 @@ describe('CachedAzureDevOpsCredentialsProvider', () => {
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
MockedClientAssertionCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
token: 'fake-client-assertion-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
@@ -89,6 +102,25 @@ describe('CachedAzureDevOpsCredentialsProvider', () => {
|
||||
expect(token).toBe('fake-client-secret-token');
|
||||
});
|
||||
|
||||
it('Should return a bearer credential when a managed identity client assertion is configured', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentityClientAssertion',
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'managedIdentityClientId',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
const { headers, type, token } = await manager.getCredentials();
|
||||
|
||||
expect(headers).toStrictEqual({
|
||||
Authorization: `Bearer fake-client-assertion-token`,
|
||||
});
|
||||
|
||||
expect(type).toBe('bearer');
|
||||
expect(token).toBe('fake-client-assertion-token');
|
||||
});
|
||||
|
||||
it('Should return a bearer credential when a managed identity is configured', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
@@ -124,6 +156,24 @@ describe('CachedAzureDevOpsCredentialsProvider', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should not refresh the managed identity client assertion token when it does not expire within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentityClientAssertion',
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'managedIdentityClientId',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedClientAssertionCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should return the managed identity token when it does not expire within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
@@ -164,6 +214,30 @@ describe('CachedAzureDevOpsCredentialsProvider', () => {
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should refresh the managed identity client assertion token when it expires within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
kind: 'ManagedIdentityClientAssertion',
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'managedIdentityClientId',
|
||||
tenantId: 'tenantId',
|
||||
});
|
||||
|
||||
MockedClientAssertionCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + minutes(9) + seconds(59),
|
||||
token: 'fake-client-assertion-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
await manager.getCredentials();
|
||||
await manager.getCredentials();
|
||||
|
||||
expect(
|
||||
MockedClientAssertionCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should refresh the managed identity token when it expires within 10 minutes', async () => {
|
||||
const manager =
|
||||
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { AzureDevOpsCredential, PersonalAccessTokenCredential } from './config';
|
||||
import {
|
||||
ClientAssertionCredential,
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
TokenCredential,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
AzureDevOpsCredentials,
|
||||
AzureDevOpsCredentialsProvider,
|
||||
} from './types';
|
||||
import { ManagedIdentityClientAssertion } from './ManagedIdentityClientAssertion';
|
||||
|
||||
type CachedAzureDevOpsCredentials = AzureDevOpsCredentials & {
|
||||
expiresAt?: number;
|
||||
@@ -59,9 +61,26 @@ export class CachedAzureDevOpsCredentialsProvider
|
||||
credential.clientSecret,
|
||||
),
|
||||
);
|
||||
|
||||
case 'ManagedIdentityClientAssertion': {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion({
|
||||
clientId: credential.managedIdentityClientId,
|
||||
});
|
||||
|
||||
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
|
||||
new ClientAssertionCredential(
|
||||
credential.tenantId,
|
||||
credential.clientId,
|
||||
() => clientAssertion.getSignedAssertion(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
case 'ManagedIdentity':
|
||||
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
|
||||
new ManagedIdentityCredential(credential.clientId),
|
||||
credential.clientId === 'system-assigned'
|
||||
? new ManagedIdentityCredential()
|
||||
: new ManagedIdentityCredential(credential.clientId),
|
||||
);
|
||||
default:
|
||||
exhaustiveCheck(credential);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type ClientAssertion = {
|
||||
/**
|
||||
* The signed assertion token
|
||||
*/
|
||||
signedAssertion: string;
|
||||
/**
|
||||
* The assertion's expiration timestamp in milliseconds, UNIX epoch time.
|
||||
*/
|
||||
expiresOnTimestamp: number;
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ManagedIdentityClientAssertion } from './ManagedIdentityClientAssertion';
|
||||
import { ManagedIdentityCredential, AccessToken } from '@azure/identity';
|
||||
|
||||
const seconds = (s: number) => s * 1000;
|
||||
const minutes = (m: number) => seconds(60) * m;
|
||||
const hours = (h: number) => minutes(60) * h;
|
||||
const MockedManagedIdentityCredential =
|
||||
ManagedIdentityCredential as jest.MockedClass<
|
||||
typeof ManagedIdentityCredential
|
||||
>;
|
||||
|
||||
jest.mock('@azure/identity');
|
||||
|
||||
describe('ManagedIdentityClientAssertion', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
token: 'fake-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should return a cached token if it does not expire within 5 minutes', async () => {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion({
|
||||
clientId: 'clientId',
|
||||
});
|
||||
|
||||
// First call to getToken to cache the token
|
||||
await clientAssertion.getSignedAssertion();
|
||||
// Second call should return the cached token
|
||||
const token = await clientAssertion.getSignedAssertion();
|
||||
|
||||
expect(token).toBe('fake-managed-identity-token');
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should obtain a new token if the cached token expires within 5 minutes', async () => {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion({
|
||||
clientId: 'clientId',
|
||||
});
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementationOnce(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + minutes(4),
|
||||
token: 'expiring-soon-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
// First call to getToken to cache the expiring token
|
||||
await clientAssertion.getSignedAssertion();
|
||||
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementationOnce(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
expiresOnTimestamp: Date.now() + hours(8),
|
||||
token: 'new-managed-identity-token',
|
||||
} as AccessToken),
|
||||
);
|
||||
|
||||
// Second call should obtain a new token
|
||||
const token = await clientAssertion.getSignedAssertion();
|
||||
|
||||
expect(token).toBe('new-managed-identity-token');
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should obtain a new token if no token is cached', async () => {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion({
|
||||
clientId: 'clientId',
|
||||
});
|
||||
|
||||
const token = await clientAssertion.getSignedAssertion();
|
||||
|
||||
expect(token).toBe('fake-managed-identity-token');
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should request a token for the correct scope', async () => {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion({
|
||||
clientId: 'clientId',
|
||||
});
|
||||
|
||||
await clientAssertion.getSignedAssertion();
|
||||
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledWith('api://AzureADTokenExchange');
|
||||
});
|
||||
|
||||
it('Should handle system-assigned managed identity', async () => {
|
||||
const clientAssertion = new ManagedIdentityClientAssertion();
|
||||
|
||||
await clientAssertion.getSignedAssertion();
|
||||
|
||||
expect(
|
||||
MockedManagedIdentityCredential.prototype.getToken,
|
||||
).toHaveBeenCalledWith('api://AzureADTokenExchange');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ManagedIdentityCredential } from '@azure/identity';
|
||||
import { ClientAssertion } from './ClientAssertion';
|
||||
|
||||
export type ManagedIdentityClientAssertionOptions = {
|
||||
clientId?: string;
|
||||
};
|
||||
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
const expiresWithinFiveMinutes = (clientAssertion: ClientAssertion) =>
|
||||
clientAssertion.expiresOnTimestamp - Date.now() <= fiveMinutes;
|
||||
|
||||
/**
|
||||
* Class representing a Managed Identity Client Assertion.
|
||||
* This class is responsible for obtaining a signed client assertion using Azure Managed Identity.
|
||||
*/
|
||||
export class ManagedIdentityClientAssertion {
|
||||
private credential: ManagedIdentityCredential;
|
||||
private clientAssertion?: ClientAssertion;
|
||||
|
||||
/**
|
||||
* Creates an instance of ManagedIdentityClientAssertion.
|
||||
* @param options - Optional parameters for the ManagedIdentityClientAssertion.
|
||||
* - clientId: The client ID of the managed identity. If not provided, 'system-assigned' is used.
|
||||
*/
|
||||
constructor(options?: ManagedIdentityClientAssertionOptions) {
|
||||
let { clientId } = options || {};
|
||||
clientId ??= 'system-assigned';
|
||||
|
||||
this.credential =
|
||||
clientId === 'system-assigned'
|
||||
? new ManagedIdentityCredential()
|
||||
: new ManagedIdentityCredential(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a signed client assertion.
|
||||
* If a valid client assertion is already cached which doesn't expire soon, it returns the cached assertion.
|
||||
* Otherwise, it obtains a new access token and creates a new client assertion.
|
||||
* @returns A promise that resolves to the signed client assertion.
|
||||
*/
|
||||
public async getSignedAssertion(): Promise<string> {
|
||||
if (
|
||||
this.clientAssertion !== undefined &&
|
||||
!expiresWithinFiveMinutes(this.clientAssertion)
|
||||
) {
|
||||
return this.clientAssertion.signedAssertion;
|
||||
}
|
||||
|
||||
const accessToken = await this.credential.getToken(
|
||||
'api://AzureADTokenExchange',
|
||||
);
|
||||
|
||||
this.clientAssertion = {
|
||||
signedAssertion: accessToken.token,
|
||||
expiresOnTimestamp: accessToken.expiresOnTimestamp,
|
||||
};
|
||||
|
||||
return accessToken.token;
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ describe('readAzureIntegrationConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential', () => {
|
||||
it('reads all values when using a managed identity client assertion credential', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
@@ -175,6 +175,62 @@ describe('readAzureIntegrationConfig', () => {
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'system-assigned',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentityClientAssertion',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'system-assigned',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity client assertion credential (without organizations)', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'system-assigned',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
kind: 'ManagedIdentityClientAssertion',
|
||||
clientId: 'id',
|
||||
managedIdentityClientId: 'system-assigned',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a managed identity credential', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credentials: [
|
||||
{
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'system-assigned',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -186,7 +242,7 @@ describe('readAzureIntegrationConfig', () => {
|
||||
{
|
||||
kind: 'ManagedIdentity',
|
||||
organizations: ['org1', 'org2'],
|
||||
clientId: 'id',
|
||||
clientId: 'system-assigned',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -71,7 +71,8 @@ export type AzureIntegrationConfig = {
|
||||
export type AzureDevOpsCredentialKind =
|
||||
| 'PersonalAccessToken'
|
||||
| 'ClientSecret'
|
||||
| 'ManagedIdentity';
|
||||
| 'ManagedIdentity'
|
||||
| 'ManagedIdentityClientAssertion';
|
||||
|
||||
/**
|
||||
* Common fields for the Azure DevOps credentials.
|
||||
@@ -109,6 +110,31 @@ export type AzureClientSecretCredential = AzureCredentialBase & {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A client assertion credential that uses a managed identity to generate a client assertion (JWT).
|
||||
* @public
|
||||
*/
|
||||
export type AzureManagedIdentityClientAssertionCredential =
|
||||
AzureCredentialBase & {
|
||||
kind: 'ManagedIdentityClientAssertion';
|
||||
/**
|
||||
* The Entra ID tenant
|
||||
*/
|
||||
tenantId: string;
|
||||
|
||||
/**
|
||||
* The client ID of the app registration you want to authenticate as.
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* The client ID of the managed identity used to generate a client assertion (JWT).
|
||||
* Set to "system-assigned" to automatically use the system-assigned managed identity.
|
||||
* For user-assigned managed identities, specify the client ID of the managed identity you want to use.
|
||||
*/
|
||||
managedIdentityClientId: 'system-assigned' | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A managed identity credential.
|
||||
* @public
|
||||
@@ -118,7 +144,7 @@ export type AzureManagedIdentityCredential = AzureCredentialBase & {
|
||||
/**
|
||||
* The clientId
|
||||
*/
|
||||
clientId: string;
|
||||
clientId: 'system-assigned' | string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,6 +162,7 @@ export type PersonalAccessTokenCredential = AzureCredentialBase & {
|
||||
*/
|
||||
export type AzureDevOpsCredentialLike = Omit<
|
||||
Partial<AzureClientSecretCredential> &
|
||||
Partial<AzureManagedIdentityClientAssertionCredential> &
|
||||
Partial<AzureManagedIdentityCredential> &
|
||||
Partial<PersonalAccessTokenCredential>,
|
||||
'kind'
|
||||
@@ -147,12 +174,14 @@ export type AzureDevOpsCredentialLike = Omit<
|
||||
*/
|
||||
export type AzureDevOpsCredential =
|
||||
| AzureClientSecretCredential
|
||||
| AzureManagedIdentityClientAssertionCredential
|
||||
| AzureManagedIdentityCredential
|
||||
| PersonalAccessTokenCredential;
|
||||
|
||||
const AzureDevOpsCredentialFields = [
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'managedIdentityClientId',
|
||||
'tenantId',
|
||||
'personalAccessToken',
|
||||
] as const;
|
||||
@@ -164,6 +193,10 @@ const AzureDevopsCredentialFieldMap = new Map<
|
||||
>([
|
||||
['ClientSecret', ['clientId', 'clientSecret', 'tenantId']],
|
||||
['ManagedIdentity', ['clientId']],
|
||||
[
|
||||
'ManagedIdentityClientAssertion',
|
||||
['clientId', 'managedIdentityClientId', 'tenantId'],
|
||||
],
|
||||
['PersonalAccessToken', ['personalAccessToken']],
|
||||
]);
|
||||
|
||||
@@ -213,9 +246,12 @@ export function readAzureIntegrationConfig(
|
||||
personalAccessToken: credential
|
||||
.getOptionalString('personalAccessToken')
|
||||
?.trim(),
|
||||
tenantId: credential.getOptionalString('tenantId'),
|
||||
clientId: credential.getOptionalString('clientId'),
|
||||
tenantId: credential.getOptionalString('tenantId')?.trim(),
|
||||
clientId: credential.getOptionalString('clientId')?.trim(),
|
||||
clientSecret: credential.getOptionalString('clientSecret')?.trim(),
|
||||
managedIdentityClientId: credential
|
||||
.getOptionalString('managedIdentityClientId')
|
||||
?.trim(),
|
||||
};
|
||||
|
||||
return result;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
AzureCredentialBase,
|
||||
AzureClientSecretCredential,
|
||||
AzureManagedIdentityCredential,
|
||||
AzureManagedIdentityClientAssertionCredential,
|
||||
PersonalAccessTokenCredential,
|
||||
AzureDevOpsCredentialLike,
|
||||
AzureDevOpsCredential,
|
||||
|
||||
Reference in New Issue
Block a user