Reorganized the module structure.

This commit is contained in:
2026-02-05 05:55:08 +01:00
parent d098081822
commit 21b469c118
13 changed files with 142 additions and 144 deletions

View File

@@ -14,8 +14,8 @@
}, },
"exports": { "exports": {
".": "./src/index.js", ".": "./src/index.js",
"./azure": "./src/azure.js", "./azure": "./src/azure/index.js",
"./graph": "./src/graph.js", "./graph": "./src/graph/index.js",
"./devops": "./src/devops.js" "./devops": "./src/devops/index.js"
} }
} }

37
src/azure/client-auth.js Normal file
View File

@@ -0,0 +1,37 @@
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
export async function getCredential(credentialType, options) {
switch (credentialType) {
case "d":
case "default":
return new DefaultAzureCredential();
case "cs":
case "clientSecret":
if (!options.tenantId || !options.clientId || !options.clientSecret) {
throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
);
}
return new ClientSecretCredential(
options.tenantId,
options.clientId,
options.clientSecret,
);
case "dc":
case "deviceCode":
if (!options.tenantId || !options.clientId) {
throw new Error(
"tenantId and clientId are required for DeviceCodeCredential",
);
}
return new DeviceCodeCredential({
tenantId: options.tenantId,
clientId: options.clientId,
userPromptCallback: (info) => {
console.log(info.message);
},
});
default:
throw new Error(`Unsupported credential type: ${credentialType}`);
}
}

9
src/azure/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* @module azure
*
* This module provides authentication functionalities for Azure services.
*
*/
export { getCredential } from "./client-auth.js";
export { loginInteractive } from "./pca-auth.js";

View File

@@ -1,4 +1,4 @@
// azure.js
import http from "node:http"; import http from "node:http";
import { URL } from "node:url"; import { URL } from "node:url";
import open, { apps } from "open"; import open, { apps } from "open";
@@ -6,51 +6,8 @@ import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import {
PublicClientApplication,
ConfidentialClientApplication,
} from "@azure/msal-node";
import {
DefaultAzureCredential,
ClientSecretCredential,
DeviceCodeCredential,
} from "@azure/identity";
export async function getCredential(credentialType, options) { import { PublicClientApplication } from "@azure/msal-node";
switch (credentialType) {
case "d":
case "default":
return new DefaultAzureCredential();
case "cs":
case "clientSecret":
if (!options.tenantId || !options.clientId || !options.clientSecret) {
throw new Error(
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
);
}
return new ClientSecretCredential(
options.tenantId,
options.clientId,
options.clientSecret,
);
case "dc":
case "deviceCode":
if (!options.tenantId || !options.clientId) {
throw new Error(
"tenantId and clientId are required for DeviceCodeCredential",
);
}
return new DeviceCodeCredential({
tenantId: options.tenantId,
clientId: options.clientId,
userPromptCallback: (info) => {
console.log(info.message);
},
});
default:
throw new Error(`Unsupported credential type: ${credentialType}`);
}
}
function fileCachePlugin(cachePath) { function fileCachePlugin(cachePath) {
return { return {
@@ -63,6 +20,7 @@ function fileCachePlugin(cachePath) {
if (ctx.cacheHasChanged) { if (ctx.cacheHasChanged) {
fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, ctx.tokenCache.serialize()); fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
fs.chmodSync(cachePath, 0o600); // Owner read/write only
} }
}, },
}; };
@@ -78,21 +36,15 @@ function generatePkce() {
return { verifier, challenge, challengeMethod: "S256" }; return { verifier, challenge, challengeMethod: "S256" };
} }
export async function loginInteractive({ appName, tenantId, clientId, scopes }) { export async function loginInteractive({ tenantId, clientId, scopes }) {
if (!tenantId) throw new Error("tenantId is required"); if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required"); if (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0) if (!Array.isArray(scopes) || scopes.length === 0)
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
// Make app name lowercase with all non-alphanumeric characters removed
// spaces replaced with dashes and all letters converted to lowercase
const sanitizedAppName = (appName || "Azure Node Login")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
const cachePath = path.join( const cachePath = path.join(
os.homedir(), os.homedir(),
`.config/${sanitizedAppName}`, `.config/sk-az-tools`,
`${clientId}-token-cache.json`, `${clientId}-token-cache.json`,
); );

View File

@@ -2,7 +2,7 @@
* A DevOps helpers module. * A DevOps helpers module.
*/ */
import { loginInteractive } from "./azure.js"; import { loginInteractive } from "../azure/index.js";
import * as azdev from "azure-devops-node-api"; import * as azdev from "azure-devops-node-api";
const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"]; const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"];

View File

@@ -1,87 +0,0 @@
import { Client } from "@microsoft/microsoft-graph-client";
import { loginInteractive } from "./azure.js";
/**
* Initialize and return a Microsoft Graph client
* along with the authentication token.
*
* @param { Object } options - Options for authentication
* @param { string } options.tenantId - The Azure AD tenant ID
* @param { string } options.clientId - The Azure AD client ID
* @returns { Object } An object containing the Graph API token and client
*/
export async function getGraphClient({ tenantId, clientId }) {
const graphApiToken = await loginInteractive({
tenantId,
clientId,
scopes: ["https://graph.microsoft.com/.default"],
});
const client = Client.init({
authProvider: (done) => {
done(null, graphApiToken.accessToken);
},
});
return { graphApiToken, client };
}
/**
* Get an Azure application by its display name.
*
* @param { Object } client
* @param { string } appName
* @returns
*/
export async function getApp(client, appName) {
const result = await client
.api("/applications")
.filter(`displayName eq '${appName}'`)
.get();
// Return the first application found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function getServicePrincipal(client, appId) {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get();
// Return the first service principal found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function createApp(client, appName) {
const app = await client.api("/applications").post({
displayName: appName,
});
if (!app || !app.appId) {
throw new Error("Failed to create application");
}
return app;
}
export async function createSp(client, appId) {
const sp = await client.api("/servicePrincipals").post({
appId,
});
if (!sp || !sp.id) {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client, spId) {
await client.api(`/servicePrincipals/${spId}`).delete();
}
export async function deleteApp(client, appObjectId) {
await client.api(`/applications/${appObjectId}`).delete();
}

32
src/graph/app.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* Get an Azure application by its display name.
*
* @param { Object } client
* @param { string } appName
* @returns
*/
export async function getApp(client, appName) {
const result = await client
.api("/applications")
.filter(`displayName eq '${appName}'`)
.get();
// Return the first application found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function createApp(client, appName) {
const app = await client.api("/applications").post({
displayName: appName,
});
if (!app || !app.appId) {
throw new Error("Failed to create application");
}
return app;
}
export async function deleteApp(client, appObjectId) {
await client.api(`/applications/${appObjectId}`).delete();
}

27
src/graph/auth.js Normal file
View File

@@ -0,0 +1,27 @@
import { loginInteractive } from "../azure/index.js";
import { Client } from "@microsoft/microsoft-graph-client";
/**
* Initialize and return a Microsoft Graph client
* along with the authentication token.
*
* @param { Object } options - Options for authentication
* @param { string } options.tenantId - The Azure AD tenant ID
* @param { string } options.clientId - The Azure AD client ID
* @returns { Object } An object containing the Graph API token and client
*/
export async function getGraphClient({ tenantId, clientId }) {
const graphApiToken = await loginInteractive({
tenantId,
clientId,
scopes: ["https://graph.microsoft.com/.default"],
});
const client = Client.init({
authProvider: (done) => {
done(null, graphApiToken.accessToken);
},
});
return { graphApiToken, client };
}

3
src/graph/index.js Normal file
View File

@@ -0,0 +1,3 @@
export * from "./auth.js";
export * from "./app.js";
export * from "./sp.js";

25
src/graph/sp.js Normal file
View File

@@ -0,0 +1,25 @@
export async function getServicePrincipal(client, appId) {
const result = await client
.api("/servicePrincipals")
.filter(`appId eq '${appId}'`)
.get();
// Return the first service principal found or null if none exists
return result.value.length > 0 ? result.value[0] : null;
}
export async function createSp(client, appId) {
const sp = await client.api("/servicePrincipals").post({
appId,
});
if (!sp || !sp.id) {
throw new Error("Failed to create service principal");
}
return sp;
}
export async function deleteSp(client, spId) {
await client.api(`/servicePrincipals/${spId}`).delete();
}