Add Azure and Microsoft Graph integration with credential management and API functions
This commit is contained in:
94
docs/PACKAGING.md
Normal file
94
docs/PACKAGING.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Developing `hello-world` (ESM) Summary
|
||||||
|
|
||||||
|
## Minimal Layout
|
||||||
|
- `package.json`, `README.md`, `src/index.js` (ESM only).
|
||||||
|
- `package.json` uses `"type": "module"` and explicit `exports`.
|
||||||
|
- `files` allow-list to control shipped content.
|
||||||
|
|
||||||
|
Example `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hello-world",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js"
|
||||||
|
},
|
||||||
|
"files": ["src", "README.md", "package.json"],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `src/index.js`:
|
||||||
|
```js
|
||||||
|
export function helloWorld() {
|
||||||
|
console.log("Hello World!!!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-modules (Subpath Exports)
|
||||||
|
- Expose sub-modules using explicit subpaths in `exports`.
|
||||||
|
- Keep public API small and intentional.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js",
|
||||||
|
"./greetings": "./src/greetings.js",
|
||||||
|
"./callouts": "./src/callouts.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `exports` vs `files`
|
||||||
|
- `exports` defines the public import surface (what consumers can import).
|
||||||
|
- `files` defines what gets packaged; supports globs and negation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": ["dist/**", "README.md", "!dist/**/*.map"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Workflow (Separate Repo, Live Updates)
|
||||||
|
- Use `npm link` for live-edit development across repos.
|
||||||
|
|
||||||
|
Publisher repo:
|
||||||
|
```bash
|
||||||
|
npm link
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumer repo:
|
||||||
|
```bash
|
||||||
|
npm link hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If you build to `dist/`, run a watch build so the consumer sees updates.
|
||||||
|
- Unlink when done:
|
||||||
|
```bash
|
||||||
|
npm unlink hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
## Distribution as a `.tgz` Artifact
|
||||||
|
- Create a tarball with `npm pack` and distribute the `.tgz` file.
|
||||||
|
- Install directly from the tarball path.
|
||||||
|
- Use `npm pack --dry-run` to verify contents before sharing.
|
||||||
|
- The `.tgz` is written to the current working directory where `npm pack` is run.
|
||||||
|
- You can redirect output with `--pack-destination` (or `pack-destination` config).
|
||||||
|
- Ship `package.json` in the artifact, but exclude `package-lock.json` (keep it in the repo for development only).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
```bash
|
||||||
|
npm pack
|
||||||
|
npm install ./hello-world-1.0.0.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with output directory:
|
||||||
|
```bash
|
||||||
|
npm pack --pack-destination ./artifacts
|
||||||
|
```
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
},
|
},
|
||||||
"description": "A set of Azure and Microsoft Graph related NodeJS modules.",
|
"description": "A set of Azure and Microsoft Graph related NodeJS modules.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/identity": "^4.13.0"
|
"@azure/identity": "^4.13.0",
|
||||||
|
"@azure/msal-node": "^5.0.3",
|
||||||
|
"@microsoft/microsoft-graph-client": "^3.0.7"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.js",
|
||||||
|
"./azure": "./src/azure.js",
|
||||||
|
"./graph": "./src/graph.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/azure.d.ts
vendored
Normal file
1
src/azure.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
194
src/azure.js
Normal file
194
src/azure.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// azure.js
|
||||||
|
import http from "node:http";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
import open, { apps } from "open";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterCacheAccess: async (ctx) => {
|
||||||
|
if (ctx.cacheHasChanged) {
|
||||||
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
|
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePkce() {
|
||||||
|
const verifier = crypto.randomBytes(32).toString("base64url"); // 43 chars, valid
|
||||||
|
const challenge = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(verifier)
|
||||||
|
.digest("base64url");
|
||||||
|
|
||||||
|
return { verifier, challenge, challengeMethod: "S256" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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/${sanitizedAppName}`,
|
||||||
|
`${clientId}-token-cache.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pca = new PublicClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId,
|
||||||
|
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
cachePlugin: fileCachePlugin(cachePath),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = await pca.getTokenCache().getAllAccounts();
|
||||||
|
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
try {
|
||||||
|
const silentResult = await pca.acquireTokenSilent({
|
||||||
|
account: accounts[0],
|
||||||
|
scopes,
|
||||||
|
});
|
||||||
|
return silentResult;
|
||||||
|
} catch (e) {
|
||||||
|
// proceed to interactive login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkce = generatePkce();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let redirectUri;
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (url.pathname !== "/callback") {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
if (!code) {
|
||||||
|
res.writeHead(400).end("Missing authorization code");
|
||||||
|
server.close();
|
||||||
|
reject(new Error("Missing authorization code"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end("Authentication complete. You may close this tab.");
|
||||||
|
server.close();
|
||||||
|
|
||||||
|
const token = await pca.acquireTokenByCode({
|
||||||
|
code,
|
||||||
|
scopes,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier: pkce.verifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(token);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1", async () => {
|
||||||
|
try {
|
||||||
|
const { port } = server.address();
|
||||||
|
redirectUri = `http://localhost:${port}/callback`;
|
||||||
|
console.log("Using redirectUri:", redirectUri);
|
||||||
|
|
||||||
|
const authUrl = await pca.getAuthCodeUrl({
|
||||||
|
scopes,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: pkce.challenge,
|
||||||
|
codeChallengeMethod: pkce.challengeMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
1
src/graph.d.ts
vendored
Normal file
1
src/graph.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
70
src/graph.js
Normal file
70
src/graph.js
Normal 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();
|
||||||
|
}
|
||||||
1
src/index.d.ts
vendored
Normal file
1
src/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
Reference in New Issue
Block a user