Merge branch 'master' into catalog/github-app-discovery

Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
Benjamin Janssens
2025-08-14 14:25:03 +02:00
2947 changed files with 147694 additions and 71166 deletions
@@ -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,
+4 -21
View File
@@ -188,7 +188,7 @@ describe('gitlab core', () => {
});
describe('getGitLabRequestOptions', () => {
it('should return Authorization header when oauthToken is provided', () => {
it('should return Authorization bearer header when a token is provided', () => {
const token = '1234567890';
const result = getGitLabRequestOptions(
configSelfHosteWithRelativePath,
@@ -202,29 +202,12 @@ describe('gitlab core', () => {
});
});
it('should return private-token header when gl-token is provided', () => {
const token = 'glpat-1234566';
const result = getGitLabRequestOptions(
configSelfHosteWithRelativePath,
token,
);
it('should return Authorization bearer header using the config token when no token is provided', () => {
const result = getGitLabRequestOptions(configSelfHosteWithRelativePath);
expect(result).toEqual({
headers: {
'PRIVATE-TOKEN': token,
},
});
});
it('should return private-token header when oauthToken is undefined', () => {
const oauthToken = undefined;
const result = getGitLabRequestOptions(
configSelfHosteWithRelativePath,
oauthToken,
);
expect(result).toEqual({
headers: {
'PRIVATE-TOKEN': configSelfHosteWithRelativePath.token,
Authorization: `Bearer ${configSelfHosteWithRelativePath.token}`,
},
});
});
+19 -14
View File
@@ -40,8 +40,9 @@ import {
export async function getGitLabFileFetchUrl(
url: string,
config: GitLabIntegrationConfig,
token?: string,
): Promise<string> {
const projectID = await getProjectId(url, config);
const projectID = await getProjectId(url, config, token);
return buildProjectUrl(url, projectID, config).toString();
}
@@ -56,20 +57,17 @@ export function getGitLabRequestOptions(
config: GitLabIntegrationConfig,
token?: string,
): { headers: Record<string, string> } {
if (token) {
// If token comes from the user and starts with "gl", it's a private token (see https://docs.gitlab.com/ee/security/token_overview.html#token-prefixes)
return {
headers: token.startsWith('gl')
? { 'PRIVATE-TOKEN': token }
: { Authorization: `Bearer ${token}` }, // Otherwise, it's a bearer token
};
const headers: Record<string, string> = {};
const accessToken = token || config.token;
if (accessToken) {
// OAuth, Personal, Project, and Group access tokens can all be passed via
// a bearer authorization header
// https://docs.gitlab.com/api/rest/authentication/#personalprojectgroup-access-tokens
headers.Authorization = `Bearer ${accessToken}`;
}
// If token not provided, fetch the integration token
const { token: configToken = '' } = config;
return {
headers: { 'PRIVATE-TOKEN': configToken },
};
return { headers };
}
// Converts
@@ -113,6 +111,7 @@ export function buildProjectUrl(
export async function getProjectId(
target: string,
config: GitLabIntegrationConfig,
token?: string,
): Promise<number> {
const url = new URL(target);
@@ -143,12 +142,18 @@ export async function getProjectId(
const response = await fetch(
repoIDLookup.toString(),
getGitLabRequestOptions(config),
getGitLabRequestOptions(config, token),
);
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
throw new Error(
'GitLab Error: 401 - Unauthorized. The access token used is either expired, or does not have permission to read the project',
);
}
throw new Error(
`GitLab Error '${data.error}', ${data.error_description}`,
);