diff --git a/.gitignore b/.gitignore index 97641d7..e5038a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Ignore node modules and config files node_modules -config.js +*config.js .env # MacOS system files diff --git a/bin/interactive-login.js b/bin/interactive-login.js new file mode 100644 index 0000000..4d35533 --- /dev/null +++ b/bin/interactive-login.js @@ -0,0 +1,12 @@ +import { loginInteractive } from "../src/auth.js"; +import { config } from "../public-config.js"; +const scopes = ["https://management.azure.com/.default"]; + +const token = await loginInteractive({ + tenantId: config.tenantId, + clientId: config.clientId, + scopes, +}); + +console.log("Access token acquired:"); +console.log(token.accessToken); diff --git a/bin/open-browser.js b/bin/open-browser.js new file mode 100644 index 0000000..71f6433 --- /dev/null +++ b/bin/open-browser.js @@ -0,0 +1,13 @@ +import open from "open"; + +async function openBrowser(url) { + try { + await open(url); + console.log(`Browser opened to ${url}`); + } catch (error) { + console.error(`Failed to open browser: ${error}`); + } +} + +const urlToOpen = "https://jmespath-playground.koszewscy.waw.pl"; +openBrowser(urlToOpen); diff --git a/bin/setup-app.js b/bin/setup-app.js index ada2a38..7274b78 100755 --- a/bin/setup-app.js +++ b/bin/setup-app.js @@ -2,6 +2,7 @@ import { exec, execSync, spawnSync } from "child_process"; import { writeFileSync } from "fs"; +import { env } from "process"; import { parseArgs } from "util"; const args = parseArgs({ @@ -9,11 +10,18 @@ const args = parseArgs({ "app-name": { type: "string", short: "a" }, help: { type: "boolean", short: "h" }, "generate-client-secret": { type: "boolean", short: "s" }, - "write-config": { type: "boolean", short: "w" }, - "write-env": { type: "boolean", short: "e" }, + "config": { type: "string", short: "c", default: "config.js" }, + "write-config": { type: "string", short: "w" }, }, }); +if (args.values["write-config"] && !["js", "env", "both"].includes(args.values["write-config"])) { + console.error( + "Invalid value for --write-config. Allowed values are: js, env, both.", + ); + process.exit(1); +} + const config = {}; if (args.values["app-name"]) { @@ -134,14 +142,17 @@ if (args.values["generate-client-secret"]) { } } -if (args.values["write-env"] || args.values["generate-client-secret"]) { - // Write the APP_ID to the .env file - const envContent = `AZ_APP_NAME="${config.appName}" +let envContent = `AZ_APP_NAME="${config.appName}" ARM_TENANT_ID=${config.tenantId} ARM_CLIENT_ID=${config.clientId} -ARM_CLIENT_SECRET=${config.clientSecret || ""} `; +if (config.clientSecret) { + envContent += `ARM_CLIENT_SECRET=${config.clientSecret}\n`; +} + +if (["env", "both"].includes(args.values["write-config"])) { + // Write the APP_ID to the .env file writeFileSync(".env", envContent); try { execSync("chmod 600 .env"); @@ -151,23 +162,29 @@ ARM_CLIENT_SECRET=${config.clientSecret || ""} ); } console.log(".env file created with application configuration."); +} else { + console.log("\nThe .env file for the application:"); + console.log(envContent); } -if (args.values["write-config"] || args.values["generate-client-secret"]) { +if (["js", "both"].includes(args.values["write-config"])) { // Save the config to the 'config.js' file. writeFileSync( - "config.js", + args.values["config"], `export const config = ${JSON.stringify(config, null, 4)};\n`, ); try { - execSync("chmod 600 config.js"); + execSync(`chmod 600 ${args.values["config"]}`); } catch (error) { console.warn( - "Could not set file permissions for config.js. Please ensure it is secured appropriately.", + `Could not set file permissions for ${args.values["config"]}. Please ensure it is secured appropriately.`, ); } - console.log("config.js file created."); + console.log(`${args.values["config"]} file created.`); +} else { + console.log(`The ${args.values["config"]} file content:`); + console.log( + `export const config = ${JSON.stringify(config, null, 4)};\n`, + ); } - -console.log("Setup complete."); diff --git a/package-lock.json b/package-lock.json index c74938d..4d19e0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "azure-node-playground", "version": "1.0.0", "dependencies": { - "@azure/identity": "^4.13.0" + "@azure/identity": "^4.13.0", + "@azure/msal-node": "^5.0.2" }, "engines": { "node": ">=24.0.0" @@ -124,6 +125,20 @@ "node": ">=20.0.0" } }, + "node_modules/@azure/identity/node_modules/@azure/msal-node": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.6.tgz", + "integrity": "sha512-XTmhdItcBckcVVTy65Xp+42xG4LX5GK+9AqAsXPXk4IqUNv+LyQo5TMwNjuFYBfAB2GTG9iSQGk+QLc03vhf3w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@azure/logger": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", @@ -159,17 +174,26 @@ } }, "node_modules/@azure/msal-node": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.6.tgz", - "integrity": "sha512-XTmhdItcBckcVVTy65Xp+42xG4LX5GK+9AqAsXPXk4IqUNv+LyQo5TMwNjuFYBfAB2GTG9iSQGk+QLc03vhf3w==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.0.2.tgz", + "integrity": "sha512-3tHeJghckgpTX98TowJoXOjKGuds0L+FKfeHJtoZFl2xvwE6RF65shZJzMQ5EQZWXzh3sE1i9gE+m3aRMachjA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.14.1", + "@azure/msal-common": "16.0.2", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, "engines": { - "node": ">=16" + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.0.2.tgz", + "integrity": "sha512-ZJ/UR7lyqIntURrIJCyvScwJFanM9QhJYcJCheB21jZofGKpP9QxWgvADANo7UkresHKzV+6YwoeZYP7P7HvUg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" } }, "node_modules/@typespec/ts-http-runtime": { diff --git a/package.json b/package.json index 836ddef..1a8249e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ }, "type": "module", "dependencies": { - "@azure/identity": "^4.13.0" + "@azure/identity": "^4.13.0", + "@azure/msal-node": "^5.0.2" } } diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..e755037 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,91 @@ +// auth.js +import http from "node:http"; +import { URL } from "node:url"; +import open from "open"; +import crypto from "node:crypto"; +import { PublicClientApplication } from "@azure/msal-node"; + +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({ 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"); + + const pca = new PublicClientApplication({ + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + }, + }); + + 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 }); } catch {} + console.log("If the browser didn't open, visit:\n" + authUrl); + } catch (e) { + try { server.close(); } catch {} + reject(e); + } + }); + }); +} \ No newline at end of file diff --git a/src/pkce.js b/src/pkce.js new file mode 100644 index 0000000..1da2a02 --- /dev/null +++ b/src/pkce.js @@ -0,0 +1,24 @@ +// pkce.js +import crypto from "node:crypto"; + +function base64UrlEncode(buf) { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +/** + * RFC7636 PKCE (S256) + * @returns {{ verifier: string, challenge: string, challengeMethod: "S256" }} + */ +export function generatePkce() { + // 32 bytes -> ~43 chars base64url, within RFC7636 43..128 range + const verifier = base64UrlEncode(crypto.randomBytes(32)); + const challenge = base64UrlEncode( + crypto.createHash("sha256").update(verifier).digest() + ); + + return { verifier, challenge, challengeMethod: "S256" }; +} \ No newline at end of file