Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/orange-spoons-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fnm": minor
---

Add support for authenticating with the registry mirror
34 changes: 34 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,37 @@ Then:

- `fnm install` will install the latest satisfying Node.js 20.x version available in the Node.js dist server
- `fnm use` will use the latest satisfying Node.js 20.x version available on your system, or prompt to install if no version matched.

### `--auth-header` / `FNM_AUTH_HEADER`

Provides an HTTP `Authorization` header for requests to the Node.js distribution mirror. This is useful when downloading from private mirrors that require authentication.

**Note:** Unlike other `FNM_*` variables, `FNM_AUTH_HEADER` is intentionally excluded from `fnm env` output to prevent credentials from being propagated to child processes.

**Usage:**

```bash
# Bearer token authentication
export FNM_AUTH_HEADER="Bearer YOUR_TOKEN_HERE"

# Basic authentication
export FNM_AUTH_HEADER="Basic $(echo -n 'user:password' | base64)"
```

**Security Recommendations:**

- **Only use with HTTPS mirrors you know and trust.** Always verify your `FNM_NODE_DIST_MIRROR` uses `https://`.
- Never commit credentials to version control.
- Handle tokens securely and rotate regularly.
- Source credentials securely from outside your shell config:

```bash
# Example: Read from a protected file (chmod 600)
export FNM_AUTH_HEADER="Bearer $(cat ~/.secrets/fnm-token)"

# Example: Use a secrets manager
export FNM_AUTH_HEADER="Bearer $(vault kv get -field=token secret/fnm)"

# Example: Use system keychain (macOS)
export FNM_AUTH_HEADER="Bearer $(security find-generic-password -s fnm-token -w)"
```
5 changes: 3 additions & 2 deletions src/archive/zip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ mod tests {
#[test_log::test]
fn test_zip_extraction() {
let temp_dir = &tempfile::tempdir().expect("Can't create a temp directory");
let response = crate::http::get("https://nodejs.org/dist/v12.0.0/node-v12.0.0-win-x64.zip")
.expect("Can't make request to Node v12.0.0 zip file");
let response =
crate::http::get("https://nodejs.org/dist/v12.0.0/node-v12.0.0-win-x64.zip", None)
.expect("Can't make request to Node v12.0.0 zip file");
Box::new(Zip::new(response))
.extract_into(temp_dir.as_ref())
.expect("Can't unzip files");
Expand Down
24 changes: 14 additions & 10 deletions src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ impl Command for Install {
return Err(Error::UninstallableVersion { version: v });
}
UserVersion::Full(Version::Lts(lts_type)) => {
let available_versions: Vec<_> = remote_node_index::list(&config.node_dist_mirror)
.map_err(|source| Error::CantListRemoteVersions { source })?;
let available_versions: Vec<_> =
remote_node_index::list(&config.node_dist_mirror, config.auth_header())
.map_err(|source| Error::CantListRemoteVersions { source })?;
let picked_version = lts_type
.pick_latest(&available_versions)
.ok_or_else(|| Error::CantFindRelevantLts {
Expand All @@ -102,8 +103,9 @@ impl Command for Install {
picked_version
}
UserVersion::Full(Version::Latest) => {
let available_versions: Vec<_> = remote_node_index::list(&config.node_dist_mirror)
.map_err(|source| Error::CantListRemoteVersions { source })?;
let available_versions: Vec<_> =
remote_node_index::list(&config.node_dist_mirror, config.auth_header())
.map_err(|source| Error::CantListRemoteVersions { source })?;
let picked_version = available_versions
.last()
.ok_or(Error::CantFindLatest)?
Expand All @@ -117,11 +119,12 @@ impl Command for Install {
picked_version
}
current_version => {
let available_versions: Vec<_> = remote_node_index::list(&config.node_dist_mirror)
.map_err(|source| Error::CantListRemoteVersions { source })?
.drain(..)
.map(|x| x.version)
.collect();
let available_versions: Vec<_> =
remote_node_index::list(&config.node_dist_mirror, config.auth_header())
.map_err(|source| Error::CantListRemoteVersions { source })?
.drain(..)
.map(|x| x.version)
.collect();

current_version
.to_version(&available_versions, config)
Expand Down Expand Up @@ -150,6 +153,7 @@ impl Command for Install {
config.installations_dir(),
safe_arch,
show_progress,
config.auth_header(),
) {
Err(err @ DownloaderError::VersionAlreadyInstalled { .. }) => {
outln!(config, Error, "{} {}", "warning:".bold().yellow(), err);
Expand Down Expand Up @@ -307,7 +311,7 @@ mod tests {
.expect("Can't install");

let available_versions: Vec<_> =
remote_node_index::list(&config.node_dist_mirror).expect("Can't get node version list");
remote_node_index::list(&config.node_dist_mirror, None).expect("Can't get node version list");
let latest_version = available_versions.last().unwrap().version.clone();

assert!(config.installations_dir().exists());
Expand Down
3 changes: 2 additions & 1 deletion src/commands/ls_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ impl super::command::Command for LsRemote {
type Error = Error;

fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
let mut all_versions = remote_node_index::list(&config.node_dist_mirror)?;
let mut all_versions =
remote_node_index::list(&config.node_dist_mirror, config.auth_header())?;

if let Some(lts) = &self.lts {
match lts {
Expand Down
15 changes: 15 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ pub struct FnmConfig {
)]
pub node_dist_mirror: Url,

/// Authentication header for Node.js distribution mirror (e.g., "Bearer token123").
/// Only use with HTTPS mirrors you trust.
#[clap(
long,
env = "FNM_AUTH_HEADER",
global = true,
hide_env_values = true
)]
auth_header: Option<String>,

/// The root directory of fnm installations.
#[clap(
long = "fnm-dir",
Expand Down Expand Up @@ -104,6 +114,7 @@ impl Default for FnmConfig {
fn default() -> Self {
Self {
node_dist_mirror: Url::parse("https://nodejs.org/dist/").unwrap(),
auth_header: None,
base_dir: None,
multishell_path: None,
log_level: LogLevel::Info,
Expand All @@ -129,6 +140,10 @@ impl FnmConfig {
self.resolve_engines.flatten().unwrap_or(true)
}

pub fn auth_header(&self) -> Option<&str> {
self.auth_header.as_deref()
}

pub fn multishell_path(&self) -> Option<&std::path::Path> {
match &self.multishell_path {
None => None,
Expand Down
5 changes: 3 additions & 2 deletions src/downloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub fn install_node_dist<P: AsRef<Path>>(
installations_dir: P,
arch: Arch,
show_progress: bool,
auth_header: Option<&str>,
) -> Result<(), Error> {
let installation_dir = PathBuf::from(installations_dir.as_ref()).join(version.v_str());

Expand All @@ -93,7 +94,7 @@ pub fn install_node_dist<P: AsRef<Path>>(
let ext = extract.file_extension();
let url = download_url(node_dist_mirror, version, arch, ext);
debug!("Going to call for {}", &url);
let response = crate::http::get(url.as_str())?;
let response = crate::http::get(url.as_str(), auth_header)?;

if !response.status().is_success() {
continue;
Expand Down Expand Up @@ -175,7 +176,7 @@ mod tests {
let version = Version::parse("12.0.0").unwrap();
let arch = Arch::X64;
let node_dist_mirror = Url::parse("https://nodejs.org/dist/").unwrap();
install_node_dist(&version, &node_dist_mirror, path, arch, false)
install_node_dist(&version, &node_dist_mirror, path, arch, false, None)
.expect("Can't install Node 12");

let mut location_path = path.join(version.v_str()).join("installation");
Expand Down
75 changes: 72 additions & 3 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! In the future, if we want to migrate to a different HTTP library,
//! we can easily change this facade instead of multiple places in the crate.

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use reqwest::{blocking::Client, IntoUrl};

#[derive(Debug, thiserror::Error, miette::Diagnostic)]
Expand All @@ -10,10 +11,78 @@ use reqwest::{blocking::Client, IntoUrl};
pub struct Error(#[from] reqwest::Error);
pub type Response = reqwest::blocking::Response;

pub fn get(url: impl IntoUrl) -> Result<Response, Error> {
fn build_headers(auth_header: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!("fnm ", env!("CARGO_PKG_VERSION"))),
);
if let Some(auth) = auth_header {
if let Ok(value) = HeaderValue::from_str(auth) {
headers.insert(AUTHORIZATION, value);
}
}
headers
}

pub fn get(url: impl IntoUrl, auth_header: Option<&str>) -> Result<Response, Error> {
Ok(Client::new()
.get(url)
// Some sites require a user agent.
.header("User-Agent", concat!("fnm ", env!("CARGO_PKG_VERSION")))
.headers(build_headers(auth_header))
.send()?)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_build_headers_without_auth() {
let headers = build_headers(None);

// User-Agent should always be set
assert!(headers.contains_key(USER_AGENT));
assert!(headers
.get(USER_AGENT)
.unwrap()
.to_str()
.unwrap()
.starts_with("fnm "));

// Authorization should not be set
assert!(!headers.contains_key(AUTHORIZATION));
}

#[test]
fn test_build_headers_with_auth() {
let headers = build_headers(Some("Bearer token123"));

// User-Agent should always be set
assert!(headers.contains_key(USER_AGENT));

// Authorization should be set with correct value
assert!(headers.contains_key(AUTHORIZATION));
assert_eq!(
headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
"Bearer token123"
);
}

#[test]
fn test_build_headers_with_basic_auth() {
let headers = build_headers(Some("Basic abc123"));

assert_eq!(
headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
"Basic abc123"
);
}

#[test]
fn test_build_headers_with_empty_auth() {
let headers = build_headers(Some(""));

// Empty string should still set the header (empty value is valid)
assert!(headers.contains_key(AUTHORIZATION));
}
}
6 changes: 3 additions & 3 deletions src/remote_node_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ pub enum Error {
/// ```rust
/// use crate::remote_node_index::list;
/// ```
pub fn list(base_url: &Url) -> Result<Vec<IndexedNodeVersion>, Error> {
pub fn list(base_url: &Url, auth_header: Option<&str>) -> Result<Vec<IndexedNodeVersion>, Error> {
let base_url = base_url.as_str().trim_end_matches('/');
let index_json_url = format!("{base_url}/index.json");
let resp = crate::http::get(&index_json_url)?
let resp = crate::http::get(&index_json_url, auth_header)?
.error_for_status()
.map_err(crate::http::Error::from)?;
let text = resp.text().map_err(crate::http::Error::from)?;
Expand All @@ -103,7 +103,7 @@ mod tests {
fn test_list() {
let base_url = Url::parse("https://nodejs.org/dist").unwrap();
let expected_version = Version::parse("12.0.0").unwrap();
let mut versions = list(&base_url).expect("Can't get HTTP data");
let mut versions = list(&base_url, None).expect("Can't get HTTP data");
assert_eq!(
versions
.drain(..)
Expand Down