@takodotid/azure-rest
Version:
Minimal Azure REST client with Entra ID (formerly AAD) authentication. Zero external dependencies.
527 lines (519 loc) • 19.9 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
AzureCliCredential: () => AzureCliCredential,
AzureClient: () => AzureClient,
AzureCredential: () => AzureCredential,
ChainedCredential: () => ChainedCredential,
ManagedIdentityCredential: () => ManagedIdentityCredential,
ServicePrincipalCredential: () => ServicePrincipalCredential,
WorkloadIdentityCredential: () => WorkloadIdentityCredential
});
module.exports = __toCommonJS(index_exports);
// src/AzureClient.ts
var AzureClient = class _AzureClient {
/**
* @param options Azure client configuration (baseUrl, credential, etc)
*/
constructor(options) {
this.options = options;
Object.defineProperty(this, "token", { enumerable: false });
}
static MAX_TOKEN_RETRIES = 3;
token = null;
/**
* Sends a GET request to the Azure REST API.
* @param path The API path
* @param options Fetch options, including headers
* @returns The fetch Response object
*/
get(path, options) {
return this.request(path, { ...options, method: "GET" });
}
/**
* Sends a POST request with a JSON body to the Azure REST API.
* @param path The API path
* @param options Fetch options, including body and headers
* @returns The fetch Response object
*/
post(path, options) {
return this.request(path, {
...options,
method: "POST"
});
}
/**
* Sends a PUT request with a JSON body to the Azure REST API.
* @param path The API path
* @param options Fetch options, including body and headers
* @returns The fetch Response object
*/
put(path, options) {
return this.request(path, {
...options,
method: "PUT"
});
}
/**
* Sends a PATCH request with a JSON body to the Azure REST API.
* @param path The API path
* @param options Fetch options, including body and headers
* @returns The fetch Response object
*/
patch(path, options) {
return this.request(path, {
...options,
method: "PATCH"
});
}
/**
* Sends a DELETE request to the Azure REST API.
* @param path The API path
* @param options Fetch options, including body and headers
* @returns The fetch Response object
*/
delete(path, options) {
return this.request(path, { ...options, method: "DELETE" });
}
/**
* Sends a request to the Azure REST API, handling token refresh and retries.
* @param path The API path (relative to baseUrl)
* @param options Optional fetch options
* @returns The fetch Response object
* @throws If token refresh fails after max retries
*/
async request(path, options) {
for (let i = 0; i <= _AzureClient.MAX_TOKEN_RETRIES; i++) {
if (this.token && this.token.expiresAt > /* @__PURE__ */ new Date()) break;
if (i === _AzureClient.MAX_TOKEN_RETRIES) {
throw new Error("Failed to refresh token after multiple attempts");
}
await this.refreshToken();
if (i > 0) await new Promise((res) => setTimeout(res, 100 * i));
}
if (!this.token) throw new Error("Token is unexpectedly null after refresh attempts");
const baseUrl = this.options.baseUrl.replace(/\/+$/, "");
const relPath = path.replace(/^\/+/, "");
const url = `${baseUrl}/${relPath}`;
return fetch(url, {
...options,
headers: {
...this.options.credential.builder ? this.options.credential.builder(this.token) : { Authorization: `Bearer ${this.token.accessToken}` },
...options?.headers
}
});
}
/**
* Refreshes the Azure access token using the provided credential helper.
* @private
*/
async refreshToken() {
this.token = await this.options.credential.helper.getToken(this.options.credential.scope);
}
};
// src/credentials/AzureCliCredential.ts
var import_node_child_process = require("child_process");
var import_node_process = __toESM(require("process"), 1);
var AzureCliCredential = class _AzureCliCredential {
constructor(options) {
this.options = options;
}
/**
* Instantiates AzureCliCredential using the AZURE_TENANT_ID environment variable (if set),
* or falls back to the current Azure CLI context.
* @returns AzureCliCredential instance
*/
static fromEnv() {
const tenantId = import_node_process.default.env.AZURE_TENANT_ID;
return new _AzureCliCredential(tenantId ? { tenantId } : {});
}
/**
* Gets an Azure access token using the Azure CLI.
* @param scope The resource scope for the token (e.g. 'https://management.azure.com/.default')
* @returns An object with accessToken, expiresAt, and tokenType
* @throws If CLI is not installed or not logged in
*/
async getToken(scope) {
try {
const tenantId = this.options.tenantId ?? await _AzureCliCredential.getCurrentTenantId();
const result = await this.getCliToken(scope, tenantId);
return this.parseRawOutput(result.stdout);
} catch (error) {
throw new Error(`Failed to get token: ${error.message}`);
}
}
/**
* Runs the Azure CLI to get an access token for the given scope and tenant.
* @param scope The resource scope
* @param tenantId The Azure tenant ID
* @returns Promise resolving to CLI stdout and stderr
* @private
*/
async getCliToken(scope, tenantId) {
return new Promise((resolve, reject) => {
try {
(0, import_node_child_process.execFile)(
"az",
["account", "get-access-token", "--output", "json", "--resource", scope.replace(".default", ""), "--tenant", tenantId],
{ cwd: import_node_process.default.cwd(), shell: true, timeout: 3e4 },
(error, stdout, stderr) => {
const { isLoginError, isNotInstallError } = _AzureCliCredential.parseCliLoginError(stderr);
if (isNotInstallError) {
reject(new Error("Azure CLI not found. Please install"));
return;
}
if (isLoginError) {
reject(new Error("Please login to Azure CLI"));
return;
}
if (error) {
reject(new Error(`Failed to get token: ${error.message}`));
return;
}
resolve({ stdout, stderr });
}
);
} catch (error) {
reject(error);
}
});
}
/**
* Gets the current tenant ID from Azure CLI context.
* @returns Promise resolving to the current tenant ID string
* @throws If CLI is not installed or not logged in
*/
static async getCurrentTenantId() {
return new Promise((resolve, reject) => {
(0, import_node_child_process.execFile)("az", ["account", "show", "--query", "tenantId", "--output", "tsv"], { cwd: import_node_process.default.cwd(), shell: true, timeout: 1e4 }, (error, stdout, stderr) => {
const { isLoginError, isNotInstallError } = _AzureCliCredential.parseCliLoginError(stderr);
if (isNotInstallError) {
reject(new Error("Azure CLI not found. Please install"));
return;
}
if (isLoginError) {
reject(new Error("Please login to Azure CLI"));
return;
}
if (error) {
reject(new Error(`Failed to detect tenantId from Azure CLI context: ${stderr}`));
return;
}
const tenantId = stdout.trim();
if (!tenantId) {
reject(new Error("Could not detect tenantId from Azure CLI context."));
return;
}
resolve(tenantId);
});
});
}
/**
* Parses the raw CLI output and returns a token + expiry object.
* @param output The stdout from Azure CLI
* @returns An object with accessToken, expiresAt, and tokenType
* @private
*/
parseRawOutput(output) {
const response = JSON.parse(output);
const token = response.accessToken;
const expiresOnTimestamp = Number.parseInt(response.expires_on, 10) * 1e3;
if (!Number.isNaN(expiresOnTimestamp)) {
return {
accessToken: token,
expiresAt: new Date(expiresOnTimestamp),
tokenType: response.tokenType
};
}
return {
accessToken: token,
expiresAt: new Date(response.expiresOn),
tokenType: response.tokenType
};
}
/**
* Checks stderr for common Azure CLI login errors.
* @param stderr The stderr string from CLI
* @returns Object with isLoginError and isNotInstallError booleans
* @private
*/
static parseCliLoginError(stderr) {
const specificScope = stderr.match("(.*)az login --scope(.*)");
const isLoginError = stderr.match("(.*)az login(.*)") && !specificScope;
const isNotInstallError = stderr.match("az:(.*)not found") ?? stderr.startsWith("'az' is not recognized");
return {
isLoginError: Boolean(isLoginError),
isNotInstallError: Boolean(isNotInstallError)
};
}
};
// src/credentials/AzureCredential.ts
var AzureCredential = class {
};
// src/credentials/ManagedIdentityCredential.ts
var ManagedIdentityCredential = class _ManagedIdentityCredential {
/**
* @param options Managed identity credential options
*/
constructor(options = {}) {
this.options = options;
}
/**
* Gets an Azure access token using the managed identity endpoint.
* @param scope The resource scope for the token
* @returns An object with token and expiresAt
* @throws If the endpoint is unavailable or token request fails
*/
async getToken(scope) {
const endpoint = this.options.identityEndpoint || "http://169.254.169.254/metadata/identity/oauth2/token";
const apiVersion = "2018-02-01";
const params = new URLSearchParams();
params.set("api-version", apiVersion);
params.set("resource", scope.replace(".default", ""));
if (this.options.clientId) {
params.set("client_id", this.options.clientId);
}
const url = `${endpoint}?${params.toString()}`;
const headers = { Metadata: "true" };
const timeoutMs = this.options.timeoutMs ?? 300;
const controller = new AbortController();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
controller.abort();
reject(new Error(`ManagedIdentityCredential: Timed out after ${timeoutMs}ms waiting for metadata endpoint (${url})`));
}, timeoutMs);
});
let response;
try {
response = await Promise.race([fetch(url, { headers, signal: controller.signal }), timeoutPromise]);
} catch (err) {
if (err.name === "AbortError" || /Timed out/.test(err.message)) {
throw new Error(`ManagedIdentityCredential: Timed out after ${timeoutMs}ms waiting for metadata endpoint (${url})`);
}
throw err;
}
if (!response.ok) {
throw new Error(`ManagedIdentityCredential: Failed to get token: ${await response.text()}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
clientId: data.client_id,
expiresAt: new Date(data.expires_on ? Number(data.expires_on) * 1e3 : Date.now() + 60 * 60 * 1e3),
// fallback 1h
tokenType: data.token_type
};
}
/**
* Instantiates ManagedIdentityCredential using environment variables.
* This expects the following environment variables to be set:
* - AZURE_CLIENT_ID: The user-assigned managed identity client ID. This is optional for system-assigned identities.
* - AZURE_MANAGED_IDENTITY_ENDPOINT or IDENTITY_ENDPOINT: The managed identity endpoint (optional, defaults to http://169.254.169.254/metadata/identity/oauth2/token)
* - AZURE_MANAGED_IDENTITY_TIMEOUT_MS: Custom timeout for metadata endpoint fetch in milliseconds (optional).
* @returns ManagedIdentityCredential instance
*/
static fromEnv() {
return new _ManagedIdentityCredential({
identityEndpoint: process.env.AZURE_MANAGED_IDENTITY_ENDPOINT || process.env.IDENTITY_ENDPOINT,
clientId: process.env.AZURE_CLIENT_ID,
timeoutMs: process.env.AZURE_MANAGED_IDENTITY_TIMEOUT_MS ? Number.parseInt(process.env.AZURE_MANAGED_IDENTITY_TIMEOUT_MS) : void 0
});
}
};
// src/credentials/ServicePrincipalCredential.ts
var import_node_url = require("url");
var ServicePrincipalCredential = class _ServicePrincipalCredential {
/**
* @param options Service principal credential options
*/
constructor(options) {
this.options = options;
}
/**
* Gets an Azure access token using the service principal credentials.
* @param scope The resource scope for the token
* @returns An object with token and expiresAt
* @throws If client secret is missing or token request fails
*/
async getToken(scope) {
if (!this.options.clientSecret) throw new Error("ServicePrincipalCredential: The client secret is not provided.");
const url = [this.options.authorityHost?.replace(/\/$/, "") ?? "https://login.microsoftonline.com", `${this.options.tenantId}/oauth2/v2.0/token`].join("/");
try {
const searchParams = {
client_id: this.options.clientId,
grant_type: "client_credentials",
scope
};
if (this.options.federated) {
Object.assign(searchParams, {
client_assertion: this.options.clientSecret,
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
});
} else {
Object.assign(searchParams, {
client_secret: this.options.clientSecret
});
}
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
body: new import_node_url.URLSearchParams(searchParams)
});
if (!response.ok) {
throw new Error(`Failed to get token: ${await response.text()}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
clientId: data.client_id ?? this.options.clientId,
expiresAt: new Date(Date.now() + data.expires_in * 1e3),
tokenType: data.token_type
};
} catch (error) {
throw new Error(`Failed to get token: ${error.stack}`);
}
}
/**
* Instantiates ServicePrincipalCredential using environment variables.
* This expects the following environment variables to be set:
* - AZURE_CLIENT_ID: The Azure AD application (client) ID
* - AZURE_CLIENT_SECRET: The client secret
* - AZURE_TENANT_ID: The Azure AD tenant ID
* - AZURE_USE_FEDERATED_AUTH: Optional, if set to "true", uses federated authentication (JWT assertion).
* @returns ServicePrincipalCredential instance
*/
static fromEnv() {
return new _ServicePrincipalCredential({
authorityHost: process.env.AZURE_AUTHORITY_HOST,
clientId: process.env.AZURE_CLIENT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET,
tenantId: process.env.AZURE_TENANT_ID,
federated: process.env.AZURE_USE_FEDERATED_AUTH === "true"
});
}
};
// src/credentials/WorkloadIdentityCredential.ts
var import_promises = require("fs/promises");
var WorkloadIdentityCredential = class _WorkloadIdentityCredential {
/**
* @param options Workload identity credential options
*/
constructor(options) {
this.options = options;
}
/**
* Gets an Azure access token using the federated token file.
* @param scope The resource scope for the token
* @returns An object with token and expiresAt
* @throws If the federated token file does not exist
*/
async getToken(scope) {
try {
const token = await (0, import_promises.readFile)(this.options.federatedTokenFile, "utf-8");
const servicePrincipal = new ServicePrincipalCredential({ ...this.options, clientSecret: token, federated: true });
return servicePrincipal.getToken(scope);
} catch (err) {
if (err.code === "ENOENT") {
throw new Error(`WorkloadIdentityCredential: Federated token file not found at ${this.options.federatedTokenFile}`);
}
throw new Error(`WorkloadIdentityCredential: Failed to get token: ${err.message}`);
}
}
/**
* Instantiates WorkloadIdentityCredential using environment variables.
* This expects the following environment variables to be set:
* - AZURE_AUTHORITY_HOST: The Azure AD authority host (optional)
* - AZURE_CLIENT_ID: The Azure AD application (client) ID
* - AZURE_FEDERATED_TOKEN_FILE: Path to the federated token file
* - AZURE_TENANT_ID: The Azure AD tenant ID
* @returns WorkloadIdentityCredential instance
*/
static fromEnv() {
return new _WorkloadIdentityCredential({
authorityHost: process.env.AZURE_AUTHORITY_HOST,
clientId: process.env.AZURE_CLIENT_ID,
federatedTokenFile: process.env.AZURE_FEDERATED_TOKEN_FILE,
tenantId: process.env.AZURE_TENANT_ID
});
}
};
// src/credentials/ChainedCredential.ts
var credentialChain = [WorkloadIdentityCredential, ManagedIdentityCredential, ServicePrincipalCredential, AzureCliCredential];
var ChainedCredential = class {
/**
* Attempts to get an Azure access token using the first available credential in the chain.
* @param scope The resource scope for the token
* @returns An object with token and expiresAt
* @throws If all credential providers fail
*/
async getToken(scope) {
const errors = [];
const debug = process.env.DEBUG?.includes("tako-azure-rest:credentials") || process.env.DEBUG?.includes("tako-azure-rest:*");
for (const Credential of credentialChain) {
const label = `[ChainedCredential] ${Credential.name}`;
let start;
if (debug) {
start = Date.now();
console.log(`${label} - trying...`);
}
try {
const result = await Credential.fromEnv().getToken(scope);
if (debug && start !== void 0) {
const ms = Date.now() - start;
console.log(`${label} - success in ${ms}ms`);
}
return result;
} catch (error) {
if (debug && start !== void 0) {
const ms = Date.now() - start;
console.log(`${label} - failed in ${ms}ms: ${error.message}`);
}
errors.push({ error, name: Credential.name });
}
}
throw new Error(`Failed to get token, errors:
${errors.map((err) => `[${err.name}] ${err.error.message}`).join("\n")}`);
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
AzureCliCredential,
AzureClient,
AzureCredential,
ChainedCredential,
ManagedIdentityCredential,
ServicePrincipalCredential,
WorkloadIdentityCredential
});
//# sourceMappingURL=index.cjs.map