Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[identity] Support Federated Identity for Service connections - new credential #29392

Merged
merged 37 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8413a7a
update the api view for service connections WI
KarishmaGhiya Feb 21, 2024
ed09ef6
service connection for WI
KarishmaGhiya Feb 21, 2024
8143362
update the WIC
KarishmaGhiya Apr 12, 2024
b947d1d
undo changes to DAC
KarishmaGhiya Apr 12, 2024
88eaf27
update the api view
KarishmaGhiya Apr 12, 2024
20b337f
update api view
KarishmaGhiya Apr 12, 2024
a40dcd5
incorporate feedback
KarishmaGhiya Apr 16, 2024
41f1d6f
incorporate docs feedback
KarishmaGhiya Apr 16, 2024
515d715
update WI error messages
KarishmaGhiya Apr 16, 2024
add42df
update the test
KarishmaGhiya Apr 16, 2024
adb3ce4
wi dont skip
KarishmaGhiya Apr 17, 2024
d1b03bb
update tests and code
KarishmaGhiya Apr 17, 2024
d60c560
test specific test
KarishmaGhiya Apr 17, 2024
b2fb6fd
use core-rest
KarishmaGhiya Apr 17, 2024
9ea3e81
add system access token in pipeline
KarishmaGhiya Apr 17, 2024
7851664
update the Json response fields
KarishmaGhiya Apr 17, 2024
987b0c6
update code
KarishmaGhiya Apr 17, 2024
03747da
using correct field name
KarishmaGhiya Apr 17, 2024
c909ac4
add credential
KarishmaGhiya Apr 23, 2024
da72605
update pr
KarishmaGhiya Apr 23, 2024
8f80a2f
update api
KarishmaGhiya Apr 23, 2024
7b7a949
format
KarishmaGhiya Apr 23, 2024
f5e5bee
fix lint
KarishmaGhiya Apr 23, 2024
320b1ee
update credential
KarishmaGhiya Apr 23, 2024
74fa416
update the design and api view
KarishmaGhiya Apr 24, 2024
42742e4
update docs
KarishmaGhiya Apr 24, 2024
f91d627
fix linting
KarishmaGhiya Apr 24, 2024
f66b82f
update service connection
KarishmaGhiya Apr 25, 2024
4a700e9
test cleanup
KarishmaGhiya Apr 25, 2024
606d70e
Update sdk/identity/identity/src/credentials/azurePipelinesServiceCon…
KarishmaGhiya Apr 25, 2024
5075748
Update sdk/identity/identity/src/credentials/azurePipelinesServiceCon…
KarishmaGhiya Apr 25, 2024
9b39405
add verification
KarishmaGhiya Apr 25, 2024
45da28e
update code
KarishmaGhiya Apr 25, 2024
9b42986
update api view
KarishmaGhiya Apr 25, 2024
e084c7b
update docs, samples, readme and lint
KarishmaGhiya Apr 26, 2024
1ca059d
update pnpm lock
KarishmaGhiya Apr 26, 2024
07d908e
formatting
KarishmaGhiya Apr 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think that AzurePipelinesCredential with a constructor that takes a required serviceConnectionId should be in consideration.

I know we've gone back and forth on this design and will support this current design, but I think

const myServiceConnectionId = "12345"
const credential = new AzurePipelinesCredential(clientId, tenantId, myServiceConnectionId)

is clear and offers a path to extending this via constructor overloads. Then, everything under the umbrella of "use this credential in Azure DevOps" can be bucketed and discovered under one name...

Copy link
Member

@maorleger maorleger Apr 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, I am proposing renaming this credential to AzurePipelineCredential -otherwise the constructor LGTM

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plural :D

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we throw if clientId is nullish? Same for tenantId?

Copy link
Member Author

@KarishmaGhiya KarishmaGhiya Apr 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the constructor won't allow a null or undefined to be passed right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also tenantId validation is done by checkTenantId function

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah 100% - for TypeScript users the type system will not allow null / undefined values there. But I think for JS users we should still have runtime validation. What do you think? Do other credentials throw if clientId or tenantid are nullish?

Also tenantId validation is done by checkTenantId function

Right, that makes sense! The error message in that function is pretty clear as well. So just clientId should be validated I suppose

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking this PR if you prefer to make an issue and get a dev-build going

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 = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (feel free to ignore): a loop would be easier to follow and modify in the future

Pseudocode:

const requiredEnvVars = ["SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", "SYSTEM_TEAMPROJECTID" ....etc]
const missingEnvVars = requiredEnvVars.filter(e => !process.env[e])
if (missingEnvVars.length > 0) { etc

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);
}
KarishmaGhiya marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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)