Skip to content

Commit

Permalink
[identity] Support Federated Identity for Service connections - new c…
Browse files Browse the repository at this point in the history
…redential (#29392)
  • Loading branch information
KarishmaGhiya authored Apr 26, 2024
1 parent f1b4f41 commit 63dce5e
Show file tree
Hide file tree
Showing 12 changed files with 1,046 additions and 446 deletions.
987 changes: 651 additions & 336 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Release History

## 4.3.0-beta.1 (Unreleased)

### Features Added

- Introducing a new credential `AzurePipelinesServiceConnectionCredential` for supporting workload identity in federation in ADO Pipelines with service connections.

### Breaking Changes

### Bug Fixes

### Other Changes

## 4.2.0 (2024-04-30)

### Features Added
Expand Down
75 changes: 39 additions & 36 deletions sdk/identity/identity/README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@azure/identity",
"sdk-type": "client",
"version": "4.2.0",
"version": "4.3.0-beta.1",
"description": "Provides credential implementations for Azure SDK libraries that can authenticate with Microsoft Entra ID",
"main": "dist/index.js",
"module": "dist-esm/src/index.js",
Expand Down
10 changes: 10 additions & 0 deletions sdk/identity/identity/review/identity.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ export interface AzureDeveloperCliCredentialOptions extends MultiTenantTokenCred
tenantId?: string;
}

// @public
export class AzurePipelinesServiceConnectionCredential implements TokenCredential {
constructor(tenantId: string, clientId: string, serviceConnectionId: string, options?: AzurePipelinesServiceConnectionCredentialOptions);
getToken(scopes: string | string[], options?: GetTokenOptions): Promise<AccessToken>;
}

// @public
export interface AzurePipelinesServiceConnectionCredentialOptions extends MultiTenantTokenCredentialOptions, CredentialPersistenceOptions, AuthorityValidationOptions {
}

// @public
export class AzurePowerShellCredential implements TokenCredential {
constructor(options?: AzurePowerShellCredentialOptions);
Expand Down
159 changes: 94 additions & 65 deletions sdk/identity/identity/samples/AzureIdentityExamples.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { ClientAssertionCredential } from "./clientAssertionCredential";
import { AuthenticationError, CredentialUnavailableError } from "../errors";
import { credentialLogger } from "../util/logging";
import { checkTenantId } from "../util/tenantIdUtils";
import {
createDefaultHttpClient,
createHttpHeaders,
createPipelineRequest,
} from "@azure/core-rest-pipeline";
import { AzurePipelinesServiceConnectionCredentialOptions } from "./azurePipelinesServiceConnectionCredentialOptions";

const credentialName = "AzurePipelinesServiceConnectionCredential";
const OIDC_API_VERSION = "7.1";
const logger = credentialLogger(credentialName);

/**
* This credential is designed to be used in ADO Pipelines with service connections
* as a setup for workload identity federation.
*/
export class AzurePipelinesServiceConnectionCredential implements TokenCredential {
private clientAssertionCredential: ClientAssertionCredential | undefined;
private serviceConnectionId: string | undefined;

/**
* AzurePipelinesServiceConnectionCredential supports Federated Identity on Azure Pipelines through Service Connections.
* @param tenantId - tenantId associated with the service connection
* @param clientId - clientId associated with the service connection
* @param serviceConnectionId - id for the service connection
* @param options - The identity client options to use for authentication.
*/
constructor(
tenantId: string,
clientId: string,
serviceConnectionId: string,
options?: AzurePipelinesServiceConnectionCredentialOptions,
) {
if (!clientId || !tenantId || !serviceConnectionId) {
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. tenantId, clientId, and serviceConnectionId are required parameters.`,
);
}

checkTenantId(logger, tenantId);
logger.info(
`Invoking AzurePipelinesServiceConnectionCredential with tenant ID: ${tenantId}, clientId: ${clientId} and service connection id: ${serviceConnectionId}`,
);

if (clientId && tenantId && serviceConnectionId) {
this.ensurePipelinesSystemVars();
const oidcRequestUrl = `${process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${process.env.SYSTEM_TEAMPROJECTID}/_apis/distributedtask/hubs/build/plans/${process.env.SYSTEM_PLANID}/jobs/${process.env.SYSTEM_JOBID}/oidctoken?api-version=${OIDC_API_VERSION}&serviceConnectionId=${this.serviceConnectionId}`;
const systemAccessToken = `${process.env.SYSTEM_ACCESSTOKEN}`;
logger.info(
`Invoking ClientAssertionCredential with tenant ID: ${tenantId}, clientId: ${clientId} and service connection id: ${serviceConnectionId}`,
);
this.clientAssertionCredential = new ClientAssertionCredential(
tenantId,
clientId,
this.requestOidcToken.bind(this, oidcRequestUrl, systemAccessToken),
options,
);
}
}

/**
* Authenticates with Microsoft Entra ID and returns an access token if successful.
* If authentication fails, a {@link CredentialUnavailableError} or {@link AuthenticationError} will be thrown with the details of the failure.
*
* @param scopes - The list of scopes for which the token will have access.
* @param options - The options used to configure any requests this
* TokenCredential implementation might make.
*/
public async getToken(
scopes: string | string[],
options?: GetTokenOptions,
): Promise<AccessToken> {
if (!this.clientAssertionCredential) {
const errorMessage = `${credentialName}: is unavailable. tenantId, clientId, and serviceConnectionId are required parameters.
To use Federation Identity in Azure Pipelines, these are required as inputs / env variables -
tenantId,
clientId,
serviceConnectionId,
"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI" &&
"SYSTEM_TEAMPROJECTID" &&
"SYSTEM_PLANID" &&
"SYSTEM_JOBID" &&
"SYSTEM_ACCESSTOKEN"
See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/troubleshoot`;
logger.error(errorMessage);
throw new CredentialUnavailableError(errorMessage);
}
logger.info("Invoking getToken() of Client Assertion Credential");
return this.clientAssertionCredential.getToken(scopes, options);
}

/**
*
* @param oidcRequestUrl - oidc request url
* @param systemAccessToken - system access token
* @returns OIDC token from Azure Pipelines
*/
private async requestOidcToken(
oidcRequestUrl: string,
systemAccessToken: string,
): Promise<string> {
logger.info("Requesting OIDC token from Azure Pipelines...");
logger.info(oidcRequestUrl);

const httpClient = createDefaultHttpClient();

const request = createPipelineRequest({
url: oidcRequestUrl,
method: "POST",
headers: createHttpHeaders({
"Content-Type": "application/json",
Authorization: `Bearer ${systemAccessToken}`,
}),
});

const response = await httpClient.sendRequest(request);
const text = response.bodyAsText;
if (!text) {
throw new AuthenticationError(
response.status,
`${credentialName}: Authenticated Failed. Received null token from OIDC request.`,
);
}
const result = JSON.parse(text);
if (result?.oidcToken) {
return result.oidcToken;
} else {
throw new AuthenticationError(
response.status,
`${credentialName}: Authentication Failed. oidcToken field not detected in the response. Response = ${JSON.stringify(
result,
)}`,
);
}
}

/**
* Ensures all system env vars are there to form the request uri for OIDC token
* @returns void
* @throws CredentialUnavailableError
*/
private ensurePipelinesSystemVars(): void {
if (
process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI &&
process.env.SYSTEM_TEAMPROJECTID &&
process.env.SYSTEM_PLANID &&
process.env.SYSTEM_JOBID &&
process.env.SYSTEM_ACCESSTOKEN
) {
return;
}
const missingEnvVars = [];
let errorMessage = "";
if (!process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) {
missingEnvVars.push("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI");
}
if (!process.env.SYSTEM_TEAMPROJECTID) missingEnvVars.push("SYSTEM_TEAMPROJECTID");
if (!process.env.SYSTEM_PLANID) missingEnvVars.push("SYSTEM_PLANID");
if (!process.env.SYSTEM_JOBID) missingEnvVars.push("SYSTEM_JOBID");
if (!process.env.SYSTEM_ACCESSTOKEN) {
errorMessage +=
"\nPlease ensure that the system access token is available in the SYSTEM_ACCESSTOKEN value; this is often most easily achieved by adding a block to the end of your pipeline yaml for the task with:\n env: \n- SYSTEM_ACCESSTOKEN: $(System.AccessToken)";
missingEnvVars.push("SYSTEM_ACCESSTOKEN");
}
if (missingEnvVars.length > 0) {
throw new CredentialUnavailableError(
`${credentialName}: is unavailable. Ensure that you're running this task in an Azure Pipeline, so that following missing system variable(s) can be defined- ${missingEnvVars.join(
", ",
)}.${errorMessage}`,
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AuthorityValidationOptions } from "./authorityValidationOptions";
import { CredentialPersistenceOptions } from "./credentialPersistenceOptions";
import { MultiTenantTokenCredentialOptions } from "./multiTenantTokenCredentialOptions";

/**
* Optional parameters for the {@link AzurePipelinesServiceConnectionCredential} class.
*/
export interface AzurePipelinesServiceConnectionCredentialOptions
extends MultiTenantTokenCredentialOptions,
CredentialPersistenceOptions,
AuthorityValidationOptions {}
3 changes: 2 additions & 1 deletion sdk/identity/identity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export {
DeviceCodeInfo,
} from "./credentials/deviceCodeCredentialOptions";
export { DeviceCodeCredentialOptions } from "./credentials/deviceCodeCredentialOptions";

export { AzurePipelinesServiceConnectionCredential } from "./credentials/azurePipelinesServiceConnectionCredential";
export { AzurePipelinesServiceConnectionCredentialOptions } from "./credentials/azurePipelinesServiceConnectionCredentialOptions";
export { AuthorizationCodeCredential } from "./credentials/authorizationCodeCredential";
export { AuthorizationCodeCredentialOptions } from "./credentials/authorizationCodeCredentialOptions";
export { AzurePowerShellCredential } from "./credentials/azurePowerShellCredential";
Expand Down
14 changes: 7 additions & 7 deletions sdk/identity/identity/src/util/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,7 @@ export interface CredentialLoggerInstance {
info(message: string): void;
warning(message: string): void;
verbose(message: string): void;
/**
* The logging functions for warning and error are intentionally left out, since we want the identity logging to be at the info level.
* Otherwise, they would look like:
*
* warning(message: string): void;
* error(err: Error): void;
*/
error(err: string): void;
}

/**
Expand Down Expand Up @@ -106,12 +100,18 @@ export function credentialLoggerInstance(
function verbose(message: string): void {
log.verbose(`${fullTitle} =>`, message);
}

function error(message: string): void {
log.error(`${fullTitle} =>`, message);
}

return {
title,
fullTitle,
info,
warning,
verbose,
error,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AzurePipelinesServiceConnectionCredential } from "../../../src";
import { env, isLiveMode } from "@azure-tools/test-recorder";
import { assert } from "@azure-tools/test-utils";

describe("AzurePipelinesServiceConnectionCredential", function () {
const scope = "https://vault.azure.net/.default";
const tenantId = env.IDENTITY_SP_TENANT_ID || env.AZURE_TENANT_ID!;
// const clientId = env.IDENTITY_SP_CLIENT_ID || env.AZURE_CLIENT_ID!;

it("authenticates with a valid service connection", async function () {
if (!isLiveMode()) {
this.skip();
}
// this serviceConnection corresponds to the Azure SDK Test Resources - LiveTestSecrets service
const existingServiceConnectionId = "0dec29c2-a766-4121-9c2e-1894f5aca5cb";
// clientId for above service connection
const clientId = "203c27cb-6778-4ecc-9bfd-9f03a61f3408";
const credential = new AzurePipelinesServiceConnectionCredential(
tenantId,
clientId,
existingServiceConnectionId,
);
try {
const token = await credential.getToken(scope);
assert.ok(token?.token);
assert.isDefined(token?.expiresOnTimestamp);
if (token?.expiresOnTimestamp) assert.ok(token?.expiresOnTimestamp > Date.now());
} catch (e) {
console.log(e);
}
});
});
1 change: 1 addition & 0 deletions sdk/identity/identity/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ extends:
AZURE_CLIENT_ID: $(IDENTITY_CLIENT_ID)
AZURE_CLIENT_SECRET: $(IDENTITY_CLIENT_SECRET)
AZURE_TENANT_ID: $(IDENTITY_TENANT_ID)
SYSTEM_ACCESSTOKEN: $(System.AccessToken)

0 comments on commit 63dce5e

Please sign in to comment.