Compare commits

...

13 Commits

Author SHA1 Message Date
9137688a55 Add create and delete application and service principal scripts 2026-02-04 00:49:24 +01:00
a805ed415f Refactor getCredential and loginInteractive functions for improved error handling and browser specification 2026-02-03 23:56:24 +01:00
1a70f7efcf Enhance openBrowser function to specify Edge as the browser and enable wait option 2026-02-03 23:56:16 +01:00
2645d6c1f4 Update interactive-login script to use Microsoft Graph API for application retrieval and adjust scopes for Azure Graph 2026-02-03 23:56:09 +01:00
a8725a7c22 Refactor getCredential function to support shorthand credential types: 'd' for Default, 'cs' for ClientSecret, and 'dc' for DeviceCode. Remove obsolete client-secret-login script. 2026-02-03 22:06:39 +01:00
0806a2b588 Add support for ClientSecret and DeviceCode credentials in getCredential function 2026-02-03 21:50:39 +01:00
0cde901ec2 Add client secret login and credential handling scripts 2026-02-03 21:50:32 +01:00
ed5253b1a1 Add README file with Azure CLI impersonation instructions 2026-01-31 22:59:11 +01:00
8acd5640d3 Add Microsoft Graph client integration and list applications script protype. 2026-01-31 08:28:52 +01:00
af0aa669cf Updated script to use new module name. 2026-01-31 07:47:39 +01:00
90c829b7ef Removed unused import. 2026-01-31 07:47:08 +01:00
ee2ecf9b2a Added editor config for JavaScript. 2026-01-31 07:46:43 +01:00
f24d7ee263 Renamed auth module to azure. 2026-01-31 07:44:41 +01:00
15 changed files with 492 additions and 62 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,mjs}]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Ignore node modules and config files # Ignore node modules and config files
node_modules node_modules
*config.js *config.js
*config.json
.env .env
# MacOS system files # MacOS system files

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# Azure Node Playground
This repository contains sample code for authenticating and interacting with Azure services using Node.js. It demonstrates how to use the Microsoft Authentication Library (MSAL) to acquire tokens and access various Azure resources.
## How to impersonate Azure CLI
Azure CLI is a well-known public application that can be used to authenticate and access Azure resources. To impersonate Azure CLI in your Node.js application, you can use the following configuration:
Use interactive login or device code flow to authenticate as Azure CLI. Use the following configuration parameters:
- **Application (client) ID**: `04b07795-8ddb-461a-bbee-02f9e1bf7b46`
- **Tenant ID**: your tenant ID
You can verify the application ID of Azure CLI by running the following command in your terminal:
```bash
az account get-access-token --scope "https://management.azure.com/.default" --debug
```
and then look for the `client_id` field in the debug output. Unfortunately, you can't use `az ad app show` to get details about this public application.

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env node
import { ClientSecretCredential } from "@azure/identity";
import { config } from "../config.js";
import { createHash } from "crypto";
// We need to wrap the async code in an IIFE
// Check, authentication using @azure/identity requires a client secret.
if (config.clientSecret) {
console.log("Client secret is set.");
// Create the client
const credential = new ClientSecretCredential(
config.tenantId,
config.clientId,
config.clientSecret,
);
const token = await credential.getToken(
"https://management.azure.com/.default",
);
if (token) {
console.log("Authentication with client secret successful.");
const hash = createHash("sha256").update(token.token).digest("hex");
console.log("SHA-256 hash of access token:", hash);
console.log("Token expires on:", new Date(token.expiresOnTimestamp).toISOString());
} else {
console.error("Authentication with client secret failed.");
process.exit(1);
}
} else {
console.warn(
"Warning: No client secret generated. Authentication may fail if the application requires a client secret.",
);
}

60
bin/create-app-and-sp.mjs Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { config } from "../public-config.js";
import {
createApp,
createSp,
getApp,
getGraphClient,
getServicePrincipal,
} from "../src/graph.js";
import { parseArgs } from "node:util";
async function usage() {
console.log("Usage: create-app-and-sp.mjs --app-name <name>");
}
async function main() {
const { client } = await getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
const args = parseArgs({
options: {
"app-name": {
type: "string",
short: "n",
},
},
});
if (!args.values["app-name"]) {
await usage();
return;
}
console.log("Will create app with name:", args.values["app-name"]);
let app = await getApp(client, args.values["app-name"]);
if (!app) {
app = await createApp(client, args.values["app-name"]);
console.log("Created app:", app.appId);
}
let sp = await getServicePrincipal(client, app.appId);
if (!sp) {
sp = await createSp(client, app.appId);
console.log("Created service principal:", sp.id);
}
console.log(`The application and associated service principal are ready.
App ID: ${app.appId}
Service Principal ID: ${sp.id}`);
}
await main().catch((e) => {
console.error("Error in main:", e);
console.error(e.stack);
process.exit(1);
});

65
bin/delete-app-and-sp.mjs Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { config } from "../public-config.js";
import {
deleteApp,
deleteSp,
getApp,
getGraphClient,
getServicePrincipal,
} from "../src/graph.js";
import { parseArgs } from "node:util";
async function usage() {
console.log("Usage: delete-app-and-sp.mjs --app-name <name>");
}
async function main() {
const { client } = await getGraphClient({
tenantId: config.tenantId,
clientId: config.clientId,
});
const args = parseArgs({
options: {
"app-name": {
type: "string",
short: "n",
},
},
});
if (!args.values["app-name"]) {
await usage();
return;
}
console.log("Will delete app with name:", args.values["app-name"]);
const app = await getApp(client, args.values["app-name"]);
if (!app) {
console.log("No app found with name:", args.values["app-name"]);
return;
}
const sp = await getServicePrincipal(client, app.appId);
if (sp && sp.id) {
await deleteSp(client, sp.id);
console.log("Deleted service principal:", sp.id);
} else {
console.log("No service principal found for appId:", app.appId);
}
if (app && app.id) {
await deleteApp(client, app.id);
console.log("Deleted app:", app.appId);
} else {
console.log("App object id missing; cannot delete application");
}
}
await main().catch((e) => {
console.error("Error in main:", e);
console.error(e.stack);
process.exit(1);
});

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import { loginInteractive } from "../src/auth.js"; import { loginInteractive } from "../src/azure.js";
import { config } from "../public-config.js"; import { config } from "../public-config.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { Client } from "@microsoft/microsoft-graph-client";
const scopes = ["https://management.azure.com/.default"]; // const scopes = ["https://management.azure.com/.default"];
const scopes = ["https://graph.microsoft.com/.default"];
let token; let token;
@@ -26,3 +28,30 @@ console.log("Access token acquired.");
const hash = createHash("sha256").update(token.accessToken).digest("hex"); const hash = createHash("sha256").update(token.accessToken).digest("hex");
console.log("SHA-256 hash of access token:", hash); console.log("SHA-256 hash of access token:", hash);
console.log("Token expires on:", token.expiresOn); console.log("Token expires on:", token.expiresOn);
const client = Client.init({
authProvider: (done) => {
done(null, token.accessToken);
},
});
let result;
result = await client
.api("/applications")
.filter("displayName eq 'Azure Node Playground Public'")
.get();
const apps = result.value ?? [];
console.log(
`Registered applications with the name 'Azure Node Playground' (${apps.length}):`,
);
if (apps.length !== 1) {
console.error(
"Expected exactly one application with the name 'Azure Node Playground'. Please ensure it is registered in your Azure AD tenant.",
);
process.exit(1);
}
console.log("Application details:", apps[0]);

47
bin/list-apps.mjs Executable file
View File

@@ -0,0 +1,47 @@
#! /usr/bin/env node
import { loginInteractive } from "../src/azure.js";
import { Client } from "@microsoft/microsoft-graph-client";
const scopes = [
"https://graph.microsoft.com/.default"
];
const { config } = await import("../public-config.js");
async function main(config) {
const token = await loginInteractive({
tenantId: config.tenantId,
clientId: config.clientId,
scopes,
});
console.log("Successfully logged in.");
const accessToken = token.accessToken;
const client = Client.init({
authProvider: (done) => {
done(null, accessToken);
},
});
const me = await client
.api("/me")
.get();
console.log("User Information:", me);
const apps = await client
.api("/applications")
.select("displayName,appId,createdDateTime")
.top(50)
.get();
console.log("Applications:");
apps.value.forEach((app) => {
console.log(`- ${app.displayName} (App ID: ${app.appId}, Created: ${app.createdDateTime})`);
});
}
main(config).catch((err) => {
console.error("Error in listing applications:", err);
process.exit(1);
});

67
bin/login.mjs Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
import { readFileSync } from "fs";
import { parseArgs } from "util";
import { getCredential } from "../src/azure.js";
let config = {};
async function main() {
// Parse command line arguments to determine which credential type to use
const args = parseArgs({
options: {
help: { type: "boolean", short: "h" },
"credential-type": { type: "string", short: "t", default: "default" },
"config": { type: "string", short: "c", default: "app-config.json" },
"tenant-id": { type: "string", default: process.env.AZURE_TENANT_ID || "" },
"client-id": { type: "string", default: process.env.AZURE_CLIENT_ID || "" },
"client-secret": { type: "string", default: process.env.AZURE_CLIENT_SECRET || "" },
"client-secret-file": { type: "string", default: process.env.AZURE_CLIENT_SECRET_FILE || "" },
},
});
if (args.values.help) {
console.log("Usage: login.mjs [options]");
console.log("Options:");
console.log(" -h, --help Show this help message");
console.log(" -c, --config Path to the configuration file (default: config.js)");
console.log(" --tenant-id Azure Tenant ID");
console.log(" --client-id Azure Client ID");
console.log(" --client-secret Azure Client Secret");
console.log(" --client-secret-file Path to file containing Azure Client Secret");
console.log(" -t, --credential-type Specify the credential type to use (default: default)");
process.exit(0);
}
// First, check if configuration file is spefiedd
const configPath = args.values.config;
if (configPath) {
// Load the JSON configuration file using readFileSync
const configFile = readFileSync(configPath, "utf-8");
config = JSON.parse(configFile);
}
// Process command line overrides
if (args.values["tenant-id"]) {
config.tenantId = args.values["tenant-id"];
}
if (args.values["client-id"]) {
config.clientId = args.values["client-id"];
}
if (args.values["client-secret"]) {
config.clientSecret = args.values["client-secret"];
} else if (args.values["client-secret-file"]) {
// Read client secret from file
config.clientSecret = readFileSync(args.values["client-secret-file"], "utf-8").trim();
}
// Create the appropriate credential based on the specified type
const credential = await getCredential(args.values["credential-type"], config);
console.log("Successfully obtained credential:", credential);
}
main().catch((error) => {
console.error("An error occurred:", error);
process.exit(1);
});

View File

@@ -1,8 +1,13 @@
import open from "open"; import open, { apps } from "open";
async function openBrowser(url) { async function openBrowser(url) {
try { try {
await open(url); await open(url, {
wait: false,
app: {
name: apps.edge
}
});
console.log(`Browser opened to ${url}`); console.log(`Browser opened to ${url}`);
} catch (error) { } catch (error) {
console.error(`Failed to open browser: ${error}`); console.error(`Failed to open browser: ${error}`);

View File

@@ -2,7 +2,6 @@
import { exec, execSync, spawnSync } from "child_process"; import { exec, execSync, spawnSync } from "child_process";
import { writeFileSync } from "fs"; import { writeFileSync } from "fs";
import { env } from "process";
import { parseArgs } from "util"; import { parseArgs } from "util";
const args = parseArgs({ const args = parseArgs({

39
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@azure/identity": "^4.13.0", "@azure/identity": "^4.13.0",
"@azure/msal-node": "^5.0.2" "@azure/msal-node": "^5.0.2",
"@microsoft/microsoft-graph-client": "^3.0.7"
}, },
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
@@ -196,6 +197,42 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@microsoft/microsoft-graph-client": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependenciesMeta": {
"@azure/identity": {
"optional": true
},
"@azure/msal-browser": {
"optional": true
},
"buffer": {
"optional": true
},
"stream-browserify": {
"optional": true
}
}
},
"node_modules/@typespec/ts-http-runtime": { "node_modules/@typespec/ts-http-runtime": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz",

View File

@@ -7,6 +7,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@azure/identity": "^4.13.0", "@azure/identity": "^4.13.0",
"@azure/msal-node": "^5.0.2" "@azure/msal-node": "^5.0.2",
"@microsoft/microsoft-graph-client": "^3.0.7"
} }
} }

View File

@@ -1,29 +1,68 @@
// auth.js // azure.js
import http from "node:http"; import http from "node:http";
import { URL } from "node:url"; import { URL } from "node:url";
import open from "open"; import open, { apps } from "open";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { PublicClientApplication } from "@azure/msal-node";
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) {
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 {
beforeCacheAccess: async (ctx) => { beforeCacheAccess: async (ctx) => {
if (fs.existsSync(cachePath)) { if (fs.existsSync(cachePath)) {
ctx.tokenCache.deserialize( ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
fs.readFileSync(cachePath, "utf8")
);
} }
}, },
afterCacheAccess: async (ctx) => { afterCacheAccess: async (ctx) => {
if (ctx.cacheHasChanged) { if (ctx.cacheHasChanged) {
fs.mkdirSync(path.dirname(cachePath), { recursive: true }); fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync( fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
cachePath,
ctx.tokenCache.serialize()
);
} }
}, },
}; };
@@ -39,16 +78,22 @@ function generatePkce() {
return { verifier, challenge, challengeMethod: "S256" }; return { verifier, challenge, challengeMethod: "S256" };
} }
export async function loginInteractive({ tenantId, clientId, scopes }) { export async function loginInteractive({ appName, 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/azure-node-playground", `.config/${sanitizedAppName}`,
`${clientId}-token-cache.json` `${clientId}-token-cache.json`,
); );
const pca = new PublicClientApplication({ const pca = new PublicClientApplication({
@@ -109,7 +154,9 @@ export async function loginInteractive({ tenantId, clientId, scopes }) {
resolve(token); resolve(token);
} catch (e) { } catch (e) {
try { server.close(); } catch {} try {
server.close();
} catch {}
reject(e); reject(e);
} }
}); });
@@ -127,10 +174,19 @@ export async function loginInteractive({ tenantId, clientId, scopes }) {
codeChallengeMethod: pkce.challengeMethod, codeChallengeMethod: pkce.challengeMethod,
}); });
try { await open(authUrl, { wait: false }); } catch {} try {
console.log("If the browser didn't open, visit:\n" + authUrl); await open(authUrl, {
wait: false,
app: {
name: apps.edge, // Enforce using Microsoft Edge browser
},
});
} catch {}
console.log("Visit:\n" + authUrl);
} catch (e) { } catch (e) {
try { server.close(); } catch {} try {
server.close();
} catch {}
reject(e); reject(e);
} }
}); });

70
src/graph.js Normal file
View File

@@ -0,0 +1,70 @@
import { Client } from "@microsoft/microsoft-graph-client";
import { loginInteractive } from "./azure.js";
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 };
}
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();
}