UNPKG

@takodotid/azure-rest

Version:

Minimal Azure REST client with Entra ID (formerly AAD) authentication. Zero external dependencies.

484 lines (478 loc) 17.8 kB
// 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 import { execFile } from "child_process"; import process2 from "process"; 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 = process2.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 { execFile( "az", ["account", "get-access-token", "--output", "json", "--resource", scope.replace(".default", ""), "--tenant", tenantId], { cwd: process2.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) => { execFile("az", ["account", "show", "--query", "tenantId", "--output", "tsv"], { cwd: process2.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 import { URLSearchParams as URLSearchParams2 } from "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 URLSearchParams2(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 import { readFile } from "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 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")}`); } }; export { AzureCliCredential, AzureClient, AzureCredential, ChainedCredential, ManagedIdentityCredential, ServicePrincipalCredential, WorkloadIdentityCredential }; //# sourceMappingURL=index.js.map