Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions change/change-93b7b060-7b85-4c0b-8bc8-961d5ceef53e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"type": "minor",
"comment": "Add authentication support for Linux",
"packageName": "ado-npm-auth",
"email": "dannyvv@microsoft.com",
"dependentChangeType": "patch"
}
]
}
3 changes: 2 additions & 1 deletion packages/ado-npm-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
lib
lib
.bin
15 changes: 7 additions & 8 deletions packages/ado-npm-auth/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,17 @@ export const run = async (args: Args): Promise<null | boolean> => {
try {
console.log("🔑 Authenticating to package feed...");

const adoOrgs = new Set<string>();
for (const adoOrg of invalidFeeds.map(
(feed) => feed.feed.adoOrganization,
)) {
adoOrgs.add(adoOrg);
const feedsToGetTokenFor = new Map<string, string>();
for (const feed of invalidFeeds.map((feed) => feed.feed)) {
feedsToGetTokenFor.set(feed.adoOrganization, feed.registry);
}

// get a token for each feed
const organizationPatMap: Record<string, string> = {};
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,
);
Expand Down
49 changes: 37 additions & 12 deletions packages/ado-npm-auth/src/npmrc/generate-npmrc-pat.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,59 @@
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
* @returns { string } a valid ADO PAT
*/
export const generateNpmrcPat = async (
organization: string,
feed: string,
encode = false,
azureAuthLocation?: string,
): Promise<string> => {
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<string> {
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`,
);
}
}
123 changes: 123 additions & 0 deletions packages/ado-npm-auth/src/npmrc/nugetCredentialProvider.ts
Original file line number Diff line number Diff line change
@@ -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<CredentialProviderResponse> {
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<CredentialProviderResponse> {
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<string> {
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<void> {
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",
});
}
54 changes: 53 additions & 1 deletion packages/ado-npm-auth/src/utils/exec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`));
}
});
});
}
67 changes: 67 additions & 0 deletions packages/ado-npm-auth/src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import https, { RequestOptions } from "https";
import fs from "fs";
import path from "path";

const defaultOptions: RequestOptions = {
port: 443,
Expand Down Expand Up @@ -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<void> {
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
});
});
}