diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..2a36ded --- /dev/null +++ b/docs/PACKAGING.md @@ -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 +``` diff --git a/package.json b/package.json index c0e80ea..806d28e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,13 @@ }, "description": "A set of Azure and Microsoft Graph related NodeJS modules.", "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" } } diff --git a/src/azure.d.ts b/src/azure.d.ts new file mode 100644 index 0000000..ab0c014 --- /dev/null +++ b/src/azure.d.ts @@ -0,0 +1 @@ +// \ No newline at end of file diff --git a/src/azure.js b/src/azure.js new file mode 100644 index 0000000..8575540 --- /dev/null +++ b/src/azure.js @@ -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); + } + }); + }); +} diff --git a/src/graph.d.ts b/src/graph.d.ts new file mode 100644 index 0000000..ab0c014 --- /dev/null +++ b/src/graph.d.ts @@ -0,0 +1 @@ +// \ No newline at end of file diff --git a/src/graph.js b/src/graph.js new file mode 100644 index 0000000..7195abe --- /dev/null +++ b/src/graph.js @@ -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(); +} diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..8337712 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1 @@ +//