Merge pull request #28437 from sanderaernouts/users/saernouts/client-assertion-credential

Added support for federated credentials using a managed identity (to generate the client assertion)
This commit is contained in:
Fredrik Adelöw
2025-05-08 17:00:58 +02:00
committed by GitHub
12 changed files with 611 additions and 24 deletions
+1
View File
@@ -62,6 +62,7 @@ export interface Config {
clientSecret?: string;
tenantId?: string;
personalAccessToken?: string;
managedIdentityClientId?: string;
}[];
/**
* PGP signing key for signing commits.
+14 -2
View File
@@ -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;
}
}
+58 -2
View File
@@ -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',
},
],
});
+40 -4
View File
@@ -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;
+1
View File
@@ -25,6 +25,7 @@ export type {
AzureCredentialBase,
AzureClientSecretCredential,
AzureManagedIdentityCredential,
AzureManagedIdentityClientAssertionCredential,
PersonalAccessTokenCredential,
AzureDevOpsCredentialLike,
AzureDevOpsCredential,