@azure/identity
Version:
Provides credential implementations for Azure SDK libraries that can authenticate with Microsoft Entra ID
293 lines • 16.8 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { credentialLogger, processEnvVars } from "../util/logging.js";
import { ClientAssertionCredential } from "./clientAssertionCredential.js";
import { CredentialUnavailableError } from "../errors.js";
import { checkTenantId } from "../util/tenantIdUtils.js";
import { readFile } from "node:fs/promises";
import { createDefaultHttpClient } from "@azure/core-rest-pipeline";
import { canParseAsX509Certificate } from "../util/certificatesUtils.js";
import { readFileSync } from "node:fs";
const credentialName = "WorkloadIdentityCredential";
/**
* Contains the list of all supported environment variable names so that an
* appropriate error message can be generated when no credentials can be
* configured.
*
* @internal
*/
export const SupportedWorkloadEnvironmentVariables = [
"AZURE_TENANT_ID",
"AZURE_CLIENT_ID",
"AZURE_FEDERATED_TOKEN_FILE",
];
const logger = credentialLogger(credentialName);
/**
* Error messages for WorkloadIdentityCredential
*/
const ErrorMessages = {
FAILED_TO_PARSE_TOKEN_PROXY: (endpoint, error) => `Failed to parse custom token proxy URL "${endpoint}": ${error}`,
INVALID_HTTPS_SCHEME: (protocol) => `Custom token endpoint must use https scheme, got "${protocol}"`,
TOKEN_ENDPOINT_NO_USER_INFO: (url) => `Custom token endpoint URL "${url}" must not contain user info`,
TOKEN_ENDPOINT_NO_QUERY: (url) => `Custom token endpoint URL "${url}" must not contain a query`,
TOKEN_ENDPOINT_NO_FRAGMENT: (url) => `Custom token endpoint URL "${url}" must not contain a fragment`,
CA_FILE_EMPTY: (file) => `CA certificate file is empty: ${file}`,
FAILED_TO_READ_CA_FILE: (file, error) => `Failed to read CA certificate file: ${file}. ${error}`,
INVALID_CA_CERTIFICATES: `Invalid CA certificate data: no valid PEM certificates found`,
INVALID_FILE_PATH: (path) => `Invalid file path provided ${path}.`,
NO_FILE_CONTENT: (path) => `No content on the file ${path}.`,
NO_CA_SOURCE: `No CA certificate source specified.`,
CLIENT_ID_REQUIRED: `clientId is a required parameter. In DefaultAzureCredential and ManagedIdentityCredential, this can be provided as an environment variable - "AZURE_CLIENT_ID".
See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/workloadidentitycredential/troubleshoot`,
TENANT_ID_REQUIRED: `tenantId is a required parameter. In DefaultAzureCredential and ManagedIdentityCredential, this can be provided as an environment variable - "AZURE_TENANT_ID".
See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/workloadidentitycredential/troubleshoot`,
TOKEN_FILE_PATH_REQUIRED: `federatedTokenFilePath is a required parameter. In DefaultAzureCredential and ManagedIdentityCredential, this can be provided as an environment variable - "AZURE_FEDERATED_TOKEN_FILE".
See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/workloadidentitycredential/troubleshoot`,
TOKEN_PROXY_NOT_SET: `AZURE_KUBERNETES_TOKEN_PROXY is not set but other custom endpoint-related environment variables are present`,
CA_FILE_AND_DATA_EXCLUSIVE: `AZURE_KUBERNETES_CA_FILE and AZURE_KUBERNETES_CA_DATA are mutually exclusive. Specify only one.`,
MISSING_ENV_VARS: `tenantId, clientId, and federatedTokenFilePath are required parameters.
In DefaultAzureCredential and ManagedIdentityCredential, these can be provided as environment variables -
"AZURE_TENANT_ID",
"AZURE_CLIENT_ID",
"AZURE_FEDERATED_TOKEN_FILE". See the troubleshooting guide for more information: https://aka.ms/azsdk/js/identity/workloadidentitycredential/troubleshoot`,
};
/**
* @internal
* Parses and validates the custom token proxy endpoint URL
*/
export function parseAndValidateCustomTokenProxy(endpoint) {
let tokenProxy;
try {
tokenProxy = new URL(endpoint);
}
catch (error) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.FAILED_TO_PARSE_TOKEN_PROXY(endpoint, error)}`);
}
if (tokenProxy.protocol !== "https:") {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.INVALID_HTTPS_SCHEME(tokenProxy.protocol)}`);
}
if (tokenProxy.username || tokenProxy.password) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TOKEN_ENDPOINT_NO_USER_INFO(tokenProxy.toString())}`);
}
if (tokenProxy.search) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TOKEN_ENDPOINT_NO_QUERY(tokenProxy.toString())}`);
}
if (tokenProxy.hash) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TOKEN_ENDPOINT_NO_FRAGMENT(tokenProxy.toString())}`);
}
if (!tokenProxy.pathname || tokenProxy.pathname === "") {
// if the path is empty, set it to "/" to avoid stripping the path from req.URL
tokenProxy.pathname = "/";
}
return tokenProxy.toString();
}
/**
* Workload Identity authentication is a feature in Azure that allows applications running on virtual machines (VMs)
* to access other Azure resources without the need for a service principal or managed identity. With Workload Identity
* authentication, applications authenticate themselves using their own identity, rather than using a shared service
* principal or managed identity. Under the hood, Workload Identity authentication uses the concept of Service Account
* Credentials (SACs), which are automatically created by Azure and stored securely in the VM. By using Workload
* Identity authentication, you can avoid the need to manage and rotate service principals or managed identities for
* each application on each VM. Additionally, because SACs are created automatically and managed by Azure, you don't
* need to worry about storing and securing sensitive credentials themselves.
* The WorkloadIdentityCredential supports Microsoft Entra Workload ID authentication on Azure Kubernetes and acquires
* a token using the SACs available in the Azure Kubernetes environment.
* Refer to <a href="https://learn.microsoft.com/azure/aks/workload-identity-overview">Microsoft Entra
* Workload ID</a> for more information.
*/
export class WorkloadIdentityCredential {
client;
azureFederatedTokenFileContent = undefined;
cacheDate = undefined;
federatedTokenFilePath;
// AKS proxy CA caching - persists across token requests
cachedTlsSettings;
cachedCaData;
caData;
caFile;
sniName;
/**
* WorkloadIdentityCredential supports Microsoft Entra Workload ID on Kubernetes.
*
* @param options - The identity client options to use for authentication.
*/
constructor(options) {
// Logging environment variables for error details
const assignedEnv = processEnvVars(SupportedWorkloadEnvironmentVariables).assigned.join(", ");
logger.info(`Found the following environment variables: ${assignedEnv}`);
const workloadIdentityCredentialOptions = options ?? {};
const tenantId = workloadIdentityCredentialOptions.tenantId || process.env.AZURE_TENANT_ID;
const clientId = workloadIdentityCredentialOptions.clientId || process.env.AZURE_CLIENT_ID;
this.federatedTokenFilePath =
workloadIdentityCredentialOptions.tokenFilePath || process.env.AZURE_FEDERATED_TOKEN_FILE;
if (tenantId) {
checkTenantId(logger, tenantId);
}
if (!clientId) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.CLIENT_ID_REQUIRED}`);
}
if (!tenantId) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TENANT_ID_REQUIRED}`);
}
if (!this.federatedTokenFilePath) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TOKEN_FILE_PATH_REQUIRED}`);
}
// Use identity binding mode only when enableAzureProxy is set
if (workloadIdentityCredentialOptions.enableAzureProxy) {
const kubernetesTokenProxy = process.env.AZURE_KUBERNETES_TOKEN_PROXY;
const kubernetesSNIName = process.env.AZURE_KUBERNETES_SNI_NAME;
const kubernetesCAFile = process.env.AZURE_KUBERNETES_CA_FILE;
const kubernetesCAData = process.env.AZURE_KUBERNETES_CA_DATA;
if (!kubernetesTokenProxy) {
// Custom token proxy is not set, while other Kubernetes-related environment variables are present,
// this is likely a configuration issue so erroring out to avoid misconfiguration
if (kubernetesSNIName || kubernetesCAFile || kubernetesCAData) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.TOKEN_PROXY_NOT_SET}`);
}
logger.info(`enableAzureProxy is true but AZURE_KUBERNETES_TOKEN_PROXY is not set, using normal authentication flow`);
}
else {
const tokenProxy = parseAndValidateCustomTokenProxy(kubernetesTokenProxy);
// CAFile and CAData are mutually exclusive, at most one can be set.
// If none of CAFile or CAData are set, the default system CA pool will be used.
if (kubernetesCAFile && kubernetesCAData) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.CA_FILE_AND_DATA_EXCLUSIVE}`);
}
this.caData = kubernetesCAData;
this.caFile = kubernetesCAFile;
this.sniName = kubernetesSNIName;
// Configure client options with AKS proxy client
const proxyClient = this.createAksProxyClient(tokenProxy);
workloadIdentityCredentialOptions.httpClient = proxyClient;
logger.info(`${credentialName}: Using proxy client for token requests`);
}
}
logger.info(`Invoking ClientAssertionCredential with tenant ID: ${tenantId}, clientId: ${workloadIdentityCredentialOptions.clientId} and federated token path: [REDACTED]`);
this.client = new ClientAssertionCredential(tenantId, clientId, this.readFileContents.bind(this), workloadIdentityCredentialOptions);
}
/**
* Creates a proxy HttpClient that intercepts token requests and redirects them to the Kubernetes endpoint
* Caching is handled at the credential level to persist across token requests
*/
createAksProxyClient(tokenEndpoint) {
const defaultClient = createDefaultHttpClient();
// Init cached TLS settings at construction time to fail fast on misconfiguration
this.cachedTlsSettings = this.getTlsSettings();
return {
sendRequest: async (request) => {
const requestUrl = new URL(request.url);
logger.info(`${credentialName}: Redirecting request to Kubernetes endpoint: ${tokenEndpoint}`);
const proxyUrl = new URL(tokenEndpoint);
// Remove leading slash from request path and join with proxy path
const requestPath = requestUrl.pathname.replace(/^\//, "");
const combinedPath = proxyUrl.pathname.endsWith("/")
? proxyUrl.pathname + requestPath
: proxyUrl.pathname + "/" + requestPath;
// Create new URL preserving query and fragment from original request
const newUrl = new URL(proxyUrl.origin);
newUrl.pathname = combinedPath;
newUrl.search = requestUrl.search;
newUrl.hash = requestUrl.hash;
request.url = newUrl.toString();
request.tlsSettings = this.getTlsSettings();
logger.info(`${credentialName}: Sending request to ${request.url}`);
// Forward the modified request with custom TLS settings
return defaultClient.sendRequest(request);
},
};
}
/**
* Gets TLS settings for the request.
* Handles a few scenarios with CA data or CA file provided.
*/
getTlsSettings() {
// No CA overrides, use default transport
if (!this.caData && !this.caFile) {
if (!this.cachedTlsSettings) {
this.cachedTlsSettings = this.sniName ? { servername: this.sniName } : {};
}
return this.cachedTlsSettings;
}
// Host provided CA bytes in AZURE_KUBERNETES_CA_DATA and can't change now
if (!this.caFile) {
if (!this.cachedTlsSettings) {
if (!canParseAsX509Certificate(this.caData)) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.INVALID_CA_CERTIFICATES}`);
}
this.cachedTlsSettings = this.sniName ? { servername: this.sniName } : {};
this.cachedTlsSettings.ca = this.caData;
}
return this.cachedTlsSettings;
}
// Host provided the CA bytes in a file whose contents it can change,
let fileContent;
try {
fileContent = readFileSync(this.caFile);
}
catch (error) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.FAILED_TO_READ_CA_FILE(this.caFile, error)}`);
}
// This can happen in the middle of CA rotation
if (fileContent.length === 0) {
if (!this.cachedTlsSettings) {
// If the transport was never created, error out here to force retrying the call later
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.CA_FILE_EMPTY(this.caFile)}`);
}
// If the transport was already created, just keep using it
return this.cachedTlsSettings;
}
// Check if CA has changed
if (!this.cachedCaData || !fileContent.equals(this.cachedCaData)) {
const caDataString = fileContent.toString("utf8");
if (!canParseAsX509Certificate(caDataString)) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.INVALID_CA_CERTIFICATES}`);
}
// CA has changed, rebuild the TLS settings with new CA pool
this.cachedTlsSettings = {
ca: caDataString,
...(this.sniName && { servername: this.sniName }),
};
this.cachedCaData = fileContent;
}
return this.cachedTlsSettings;
}
/**
* Authenticates with Microsoft Entra ID and returns an access token if successful.
* If authentication fails, a {@link CredentialUnavailableError} 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.
*/
async getToken(scopes, options) {
if (!this.client) {
const errorMessage = `${credentialName}: is unavailable. ${ErrorMessages.MISSING_ENV_VARS}`;
logger.info(errorMessage);
throw new CredentialUnavailableError(errorMessage);
}
logger.info("Invoking getToken() of Client Assertion Credential");
return this.client.getToken(scopes, options);
}
async readFileContents() {
// Cached assertions expire after 5 minutes
if (this.cacheDate !== undefined && Date.now() - this.cacheDate >= 1000 * 60 * 5) {
this.azureFederatedTokenFileContent = undefined;
}
if (!this.federatedTokenFilePath) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.INVALID_FILE_PATH(this.federatedTokenFilePath)}`);
}
if (!this.azureFederatedTokenFileContent) {
const file = await readFile(this.federatedTokenFilePath, "utf8");
const value = file.trim();
if (!value) {
throw new CredentialUnavailableError(`${credentialName}: is unavailable. ${ErrorMessages.NO_FILE_CONTENT(this.federatedTokenFilePath)}`);
}
else {
this.azureFederatedTokenFileContent = value;
this.cacheDate = Date.now();
}
}
return this.azureFederatedTokenFileContent;
}
}
//# sourceMappingURL=workloadIdentityCredential.js.map