Initial commit
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// ── Mock global fetch ───────────────────────────────────────────────────────
|
||||
const mockFetch = mock();
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
import {
|
||||
fromEnvServicePrincipal,
|
||||
fromManagedIdentity,
|
||||
fromWorkloadIdentity,
|
||||
resolveAllIdentities,
|
||||
resolveToken,
|
||||
} from "../../../src/providers/azure/auth";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setEnv(kv: Record<string, string>) {
|
||||
for (const [k, v] of Object.entries(kv)) process.env[k] = v;
|
||||
}
|
||||
|
||||
function clearEnv(keys: string[]) {
|
||||
for (const k of keys) delete process.env[k];
|
||||
}
|
||||
|
||||
function tokenResponse(overrides: Record<string, unknown> = {}) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: overrides.access_token ?? "eyJ...fake-token",
|
||||
expires_in: overrides.expires_in ?? 3600,
|
||||
expires_on: overrides.expires_on ?? String(Date.now() / 1000 + 3600),
|
||||
tenant_id: overrides.tenant_id ?? "tenant-123",
|
||||
client_id: overrides.client_id ?? "client-456",
|
||||
}),
|
||||
{ status: overrides.status ?? 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"AZURE_TENANT_ID",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"ARM_CLIENT_ID",
|
||||
"ARM_CLIENT_SECRET",
|
||||
"ARM_TENANT_ID",
|
||||
"TENANT_ID",
|
||||
"AZURE_FEDERATED_TOKEN_FILE",
|
||||
"ARM_OIDC_TOKEN_FILE_PATH",
|
||||
"IDENTITY_ENDPOINT",
|
||||
"IDENTITY_HEADER",
|
||||
];
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Azure auth", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("mock fetch not configured for this test")),
|
||||
);
|
||||
clearEnv(ENV_KEYS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearEnv(ENV_KEYS);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// resolveToken — credential chain
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("resolveToken", () => {
|
||||
it("throws when no credentials are available", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("connect ECONNREFUSED")),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveToken("https://vault.azure.net/.default", undefined, 1000),
|
||||
).rejects.toThrow("No Azure credentials available");
|
||||
});
|
||||
|
||||
it("uses env service principal when configured", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "sp-token" })),
|
||||
);
|
||||
|
||||
// Use unique scope to avoid cache contamination from test 1
|
||||
const result = await resolveToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(result.token).toBe("sp-token");
|
||||
expect(result.tenantId).toBe("tenant-123");
|
||||
});
|
||||
|
||||
it("falls through to managed identity when env vars incomplete", async () => {
|
||||
// Only client_id set — no secret or tenant
|
||||
setEnv({ AZURE_CLIENT_ID: "c1" });
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "mi-token" })),
|
||||
);
|
||||
|
||||
// Use a different scope to avoid cache contamination
|
||||
const result = await resolveToken(
|
||||
"https://management.azure.com/.default",
|
||||
);
|
||||
|
||||
expect(result.token).toBe("mi-token");
|
||||
});
|
||||
|
||||
it("caches tokens and reuses them", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t2",
|
||||
AZURE_CLIENT_ID: "c2",
|
||||
AZURE_CLIENT_SECRET: "s2",
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
mockFetch.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(
|
||||
tokenResponse({ access_token: `call-${callCount}` }),
|
||||
);
|
||||
});
|
||||
|
||||
// Use unique scope to isolate cache from other tests
|
||||
const scope = "https://graph.microsoft.com/.default";
|
||||
const r1 = await resolveToken(scope);
|
||||
const r2 = await resolveToken(scope);
|
||||
|
||||
// Both calls should return the same cached token
|
||||
expect(r1.token).toBe(r2.token);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromEnvServicePrincipal
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromEnvServicePrincipal", () => {
|
||||
it("returns null when AZURE_TENANT_ID is missing", () => {
|
||||
setEnv({ AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AZURE_CLIENT_ID is missing", () => {
|
||||
setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_SECRET: "s1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when AZURE_CLIENT_SECRET is missing", () => {
|
||||
setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1" });
|
||||
expect(fromEnvServicePrincipal()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns credential when all env vars are set", () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
const cred = fromEnvServicePrincipal();
|
||||
expect(cred).not.toBeNull();
|
||||
expect(cred!.label).toBe("env-service-principal");
|
||||
});
|
||||
|
||||
it("obtains a token successfully with valid credentials", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "tok" })),
|
||||
);
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("tok");
|
||||
});
|
||||
|
||||
it("throws on non-200 response", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "bad-client",
|
||||
AZURE_CLIENT_SECRET: "bad-secret",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "x", status: 401 })),
|
||||
);
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
await expect(
|
||||
cred.getToken("https://vault.azure.net/.default"),
|
||||
).rejects.toThrow("Client credentials token request failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromManagedIdentity
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromManagedIdentity", () => {
|
||||
it("returns a credential unconditionally", () => {
|
||||
const cred = fromManagedIdentity();
|
||||
expect(cred.label).toBe("managed-identity");
|
||||
});
|
||||
|
||||
it("obtains a token via IMDS", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "mi-tok" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("mi-tok");
|
||||
});
|
||||
|
||||
it("obtains a token via App Service endpoint", async () => {
|
||||
setEnv({
|
||||
IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/",
|
||||
IDENTITY_HEADER: "abc123header",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "app-svc-tok" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.token).toBe("app-svc-tok");
|
||||
});
|
||||
|
||||
it("handles expires_on as relative seconds (App Service)", async () => {
|
||||
setEnv({
|
||||
IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/",
|
||||
IDENTITY_HEADER: "hdr",
|
||||
});
|
||||
|
||||
// App Service returns expires_on as relative seconds (< 1M)
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ expires_on: "3600" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const now = Date.now() / 1000;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// Should be treated as relative (now + 3600), not absolute epoch 3600
|
||||
expect(token.expiresOn).toBeGreaterThan(1_000_000_000);
|
||||
expect(token.expiresOn).toBeGreaterThan(now);
|
||||
expect(token.expiresOn).toBeLessThan(now + 3700);
|
||||
});
|
||||
|
||||
it("handles expires_on as absolute epoch (IMDS)", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ expires_on: "2000000000" })),
|
||||
);
|
||||
|
||||
const cred = fromManagedIdentity();
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
expect(token.expiresOn).toBe(2_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// fromWorkloadIdentity
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("fromWorkloadIdentity", () => {
|
||||
it("returns null without AZURE_FEDERATED_TOKEN_FILE", () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
});
|
||||
expect(fromWorkloadIdentity()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// resolveAllIdentities
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("resolveAllIdentities", () => {
|
||||
it("returns empty array when all sources fail", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("network error")),
|
||||
);
|
||||
|
||||
const results = await resolveAllIdentities(
|
||||
"https://management.azure.com/.default",
|
||||
500,
|
||||
);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns tokens from each available source", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve(tokenResponse({ access_token: "sp-tok" })),
|
||||
);
|
||||
|
||||
const results = await resolveAllIdentities(
|
||||
"https://management.azure.com/.default",
|
||||
500,
|
||||
);
|
||||
|
||||
// Both env SP and managed identity succeed
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].source).toBe("env-service-principal");
|
||||
expect(results[0].token.token).toBe("sp-tok");
|
||||
expect(results[1].source).toBe("managed-identity");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ARM_TENANT_ID / ARM_CLIENT_ID fallback
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("ARM_ env var fallbacks", () => {
|
||||
it("uses ARM_TENANT_ID when AZURE_TENANT_ID is not set", async () => {
|
||||
setEnv({
|
||||
ARM_TENANT_ID: "arm-t",
|
||||
AZURE_CLIENT_ID: "c1",
|
||||
AZURE_CLIENT_SECRET: "s1",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() => Promise.resolve(tokenResponse()));
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// The credential should work — tenant comes from ARM_TENANT_ID
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses ARM_CLIENT_ID and ARM_CLIENT_SECRET when AZURE_ variants missing", async () => {
|
||||
setEnv({
|
||||
AZURE_TENANT_ID: "t1",
|
||||
ARM_CLIENT_ID: "arm-c",
|
||||
ARM_CLIENT_SECRET: "arm-s",
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation(() => Promise.resolve(tokenResponse()));
|
||||
|
||||
const cred = fromEnvServicePrincipal()!;
|
||||
const token = await cred.getToken("https://vault.azure.net/.default");
|
||||
|
||||
// The credential should work — client/secret from ARM_* vars
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user