diff --git a/change/change-93b7b060-7b85-4c0b-8bc8-961d5ceef53e.json b/change/change-93b7b060-7b85-4c0b-8bc8-961d5ceef53e.json new file mode 100644 index 00000000..0e56c1e6 --- /dev/null +++ b/change/change-93b7b060-7b85-4c0b-8bc8-961d5ceef53e.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "type": "minor", + "comment": "Add authentication support for Linux", + "packageName": "ado-npm-auth", + "email": "dannyvv@microsoft.com", + "dependentChangeType": "patch" + } + ] +} \ No newline at end of file diff --git a/packages/ado-npm-auth/.gitignore b/packages/ado-npm-auth/.gitignore index 90d45b85..d0d99d70 100644 --- a/packages/ado-npm-auth/.gitignore +++ b/packages/ado-npm-auth/.gitignore @@ -1,2 +1,3 @@ dist -lib \ No newline at end of file +lib +.bin \ No newline at end of file diff --git a/packages/ado-npm-auth/src/cli.ts b/packages/ado-npm-auth/src/cli.ts index 9ad44b86..4213fc5b 100644 --- a/packages/ado-npm-auth/src/cli.ts +++ b/packages/ado-npm-auth/src/cli.ts @@ -51,18 +51,17 @@ export const run = async (args: Args): Promise => { try { console.log("🔑 Authenticating to package feed..."); - const adoOrgs = new Set(); - for (const adoOrg of invalidFeeds.map( - (feed) => feed.feed.adoOrganization, - )) { - adoOrgs.add(adoOrg); + const feedsToGetTokenFor = new Map(); + for (const feed of invalidFeeds.map((feed) => feed.feed)) { + feedsToGetTokenFor.set(feed.adoOrganization, feed.registry); } // get a token for each feed const organizationPatMap: Record = {}; - for (const adoOrg of adoOrgs) { - organizationPatMap[adoOrg] = await generateNpmrcPat( - adoOrg, + for (const [org, feed] of feedsToGetTokenFor) { + organizationPatMap[org] = await generateNpmrcPat( + org, + feed, false, args.azureAuthLocation, ); diff --git a/packages/ado-npm-auth/src/npmrc/generate-npmrc-pat.ts b/packages/ado-npm-auth/src/npmrc/generate-npmrc-pat.ts index 91b976ce..6e8414f7 100644 --- a/packages/ado-npm-auth/src/npmrc/generate-npmrc-pat.ts +++ b/packages/ado-npm-auth/src/npmrc/generate-npmrc-pat.ts @@ -1,6 +1,7 @@ -import { hostname } from "os"; +import { hostname, platform } from "os"; import { AdoPatResponse, adoPat } from "../azureauth/ado.js"; import { toBase64 } from "../utils/encoding.js"; +import { credentialProviderPat } from "./nugetCredentialProvider.js"; /** * Generates a valid ADO PAT, scoped for vso.packaging in the given ado organization, 30 minute timeout @@ -8,27 +9,51 @@ import { toBase64 } from "../utils/encoding.js"; */ export const generateNpmrcPat = async ( organization: string, + feed: string, encode = false, azureAuthLocation?: string, ): Promise => { const name = `${hostname()}-${organization}`; - const pat = await adoPat( - { - promptHint: `${name} .npmrc PAT`, - organization, - displayName: `${name}-npmrc-pat`, - scope: ["vso.packaging"], - timeout: "30", - output: "json", - }, + const rawToken = await getRawToken( + name, + organization, + feed, azureAuthLocation, ); - const rawToken = (pat as AdoPatResponse).token; - if (encode) { return toBase64(rawToken); } return rawToken; }; + +async function getRawToken( + name: string, + organization: string, + feed: string, + azureAuthLocation?: string, +): Promise { + switch (platform()) { + case "win32": + case "darwin": + const pat = await adoPat( + { + promptHint: `Authenticate to ${organization} to generate a temporary token for npm`, + organization: `https://dev.azure.com/${organization}`, + displayName: name, + scope: ["vso.packaging"], + timeout: "30m", + }, + azureAuthLocation, + ); + return (pat as AdoPatResponse).token; + case "linux": + const cpPat = await credentialProviderPat(feed); + return cpPat.Password; + default: + throw new Error( + `Platform ${platform()} is not supported for ADO authentication`, + ); + } +} diff --git a/packages/ado-npm-auth/src/npmrc/nugetCredentialProvider.ts b/packages/ado-npm-auth/src/npmrc/nugetCredentialProvider.ts new file mode 100644 index 00000000..2c075145 --- /dev/null +++ b/packages/ado-npm-auth/src/npmrc/nugetCredentialProvider.ts @@ -0,0 +1,123 @@ +import os from "os"; +import fs from "fs"; +import path from "path"; +import { downloadFile } from "../utils/request.js"; +import { execProcess } from "../utils/exec.js"; + +const CredentialProviderVersion = "1.4.1"; +const OutputDir = path.join( + __dirname, + "..", + ".bin", + "CredentialProvider.Microsoft", + "v" + CredentialProviderVersion, +); + +interface CredentialProviderResponse { + Username: string; + Password: string; +} + +export async function credentialProviderPat( + registry: string, +): Promise { + const nugetFeedUrl = toNugetUrl(registry); + const toolPath = await getCredentialProvider(); + return await invokeCredentialProvider(toolPath, nugetFeedUrl); +} + +function toNugetUrl(registry: string): string { + if (!registry.endsWith("/npm/registry/")) { + throw new Error( + `Registry URL ${registry} is not a valid Azure Artifacts npm registry URL. Expected it to end with '/npm/registry/'`, + ); + } + return ( + "https://" + registry.replace("/npm/registry/", "/nuget/v3/index.json") + ); +} + +async function invokeCredentialProvider( + toolPath: string, + nugetFeedUrl: string, +): Promise { + let response = ""; + await execProcess(toolPath, ["-U", nugetFeedUrl, "-I", "-F", "Json"], { + stdio: "pipe", + processStdOut: (data: string) => { + response += data; + }, + processStdErr: (data: string) => { + console.error(data); + }, + }); + try { + let value = JSON.parse(response); + return value as CredentialProviderResponse; + } catch (error) { + throw new Error(`Failed to parse CredentialProvider output: ${response}`); + } +} + +function tryFileExists(executable: string): string | undefined { + if (fs.existsSync(executable)) { + return executable; + } else if (fs.existsSync(executable + ".exe")) { + return executable + ".exe"; + } + return undefined; +} + +async function getCredentialProvider(): Promise { + let toolPath = tryFileExists( + path.join( + os.homedir(), + ".nuget", + "plugins", + "netcore", + "CredentialProvider.Microsoft", + "CredentialProvider.Microsoft", + ), + ); + if (toolPath) { + return toolPath; + } + + const downloadedFilePath = path.join( + OutputDir, + "plugins", + "netcore", + "CredentialProvider.Microsoft", + "CredentialProvider.Microsoft", + ); + toolPath = tryFileExists(downloadedFilePath); + if (toolPath) { + return toolPath; + } + + await downloadCredentialProvider(); + toolPath = tryFileExists(downloadedFilePath); + if (toolPath) { + fs.chmodSync(toolPath, 0o755); + } else { + throw new Error( + `CredentialProvider was not found at expected path after download: ${toolPath}`, + ); + } + + return toolPath; +} + +async function downloadCredentialProvider(): Promise { + const downloadUrl = `https://github.com/microsoft/artifacts-credprovider/releases/download/v${CredentialProviderVersion}/Microsoft.Net8.${os.platform()}-${os.arch()}.NuGet.CredentialProvider.tar.gz`; + const downloadPath = path.join( + OutputDir, + "CredentialProvider.Microsoft.tar.gz", + ); + + console.log(`🌐 Downloading ${downloadUrl}`); + await downloadFile(downloadUrl, downloadPath); + await execProcess("tar", ["-xzf", downloadPath, "-C", OutputDir], { + stdio: "inherit", + }); +} diff --git a/packages/ado-npm-auth/src/utils/exec.ts b/packages/ado-npm-auth/src/utils/exec.ts index c3b7e94b..7caa0eb4 100644 --- a/packages/ado-npm-auth/src/utils/exec.ts +++ b/packages/ado-npm-auth/src/utils/exec.ts @@ -1,4 +1,56 @@ -import { exec as _exec } from "node:child_process"; +import { exec as _exec, spawn } from "node:child_process"; import { promisify } from "node:util"; export const exec = promisify(_exec); + +/** + * Executes a command as a child process. + * @param tool The command to run. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns A promise that resolves when the process exits. + */ +export function execProcess( + tool: string, + args: string[], + options?: { + cwd?: string; + env?: { [key: string]: string }; + stdio?: "pipe" | "inherit" | "ignore"; + shell?: boolean | string; + processStdOut?: (data: string) => void; + processStdErr?: (data: string) => void; + }, +): Promise { + return new Promise((resolve, reject) => { + const cwd = options?.cwd || process.cwd(); + console.log(`🚀 Launching [${tool} ${args.join(" ")}] in ${cwd}`); + const result = spawn(tool, args, { + cwd: cwd, + env: options?.env || process.env, + stdio: options?.stdio || "inherit", + shell: options?.shell || false, + }); + + if (options?.stdio === "pipe") { + result.stdout?.setEncoding("utf8"); + result.stdout?.on("data", function (data: Buffer) { + const strData = data.toString("utf8"); + options?.processStdOut?.(strData); + }); + + result.stderr?.setEncoding("utf8"); + result.stderr?.on("data", function (data: Buffer) { + const strData = data.toString("utf8"); + options?.processStdErr?.(strData); + }); + } + result.on("exit", (code) => { + if (code == 0) { + resolve(); + } else { + reject(new Error(`Process ${tool} exited with code ${code}`)); + } + }); + }); +} diff --git a/packages/ado-npm-auth/src/utils/request.ts b/packages/ado-npm-auth/src/utils/request.ts index 70c01bb4..00b6875a 100644 --- a/packages/ado-npm-auth/src/utils/request.ts +++ b/packages/ado-npm-auth/src/utils/request.ts @@ -1,4 +1,6 @@ import https, { RequestOptions } from "https"; +import fs from "fs"; +import path from "path"; const defaultOptions: RequestOptions = { port: 443, @@ -51,3 +53,68 @@ export const makeRequest = async (options: RequestOptions) => { req.end(); }); }; + +/** + * Downloads a file from a URL to a local path. + * @param url The URL of the file to download. + * @param downloadPath The local path to save the downloaded file. + * @returns A promise that resolves when the download is complete. + */ +export async function downloadFile( + url: string, + downloadPath: string, +): Promise { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + // Handle redirects + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + downloadFile(redirectUrl, downloadPath).then(resolve).catch(reject); + return; + } else { + reject(new Error("Redirect without location header")); + return; + } + } + + // Check for successful response + if (response.statusCode !== 200) { + reject( + new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`), + ); + return; + } + + let downloadStream: fs.WriteStream; + try { + const downloadDir = path.dirname(downloadPath); + if (!fs.existsSync(downloadDir)) { + fs.mkdirSync(downloadDir, { recursive: true }); + } + + downloadStream = fs.createWriteStream(downloadPath); + } catch (error) { + reject(error); + return; + } + + // Handle stream errors + downloadStream.on("error", (error) => { + reject(error); + }); + + // Pipe the response to the file stream + response.pipe(downloadStream); + + // The whole response has been received and written + downloadStream.on("finish", () => { + resolve(); + }); + }) + .on("error", (error) => { + reject(error); // Reject on request error + }); + }); +}