Compare commits

...

8 Commits

10 changed files with 397 additions and 58 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Ignore node modules and config files
node_modules
*config.js
*config.json
.env
# 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

@@ -3,8 +3,10 @@
import { loginInteractive } from "../src/azure.js";
import { config } from "../public-config.js";
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;
@@ -26,3 +28,30 @@ console.log("Access token acquired.");
const hash = createHash("sha256").update(token.accessToken).digest("hex");
console.log("SHA-256 hash of access token:", hash);
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]);

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) {
try {
await open(url);
await open(url, {
wait: false,
app: {
name: apps.edge
}
});
console.log(`Browser opened to ${url}`);
} catch (error) {
console.error(`Failed to open browser: ${error}`);

View File

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