diff --git a/.changeset/orange-spoons-build.md b/.changeset/orange-spoons-build.md new file mode 100644 index 000000000..ef1516c64 --- /dev/null +++ b/.changeset/orange-spoons-build.md @@ -0,0 +1,5 @@ +--- +"fnm": minor +--- + +Add support for authenticating with the registry mirror diff --git a/docs/configuration.md b/docs/configuration.md index a205a4e44..e03629125 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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)" + ``` diff --git a/src/archive/zip.rs b/src/archive/zip.rs index 37a69a365..513192eec 100644 --- a/src/archive/zip.rs +++ b/src/archive/zip.rs @@ -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"); diff --git a/src/commands/install.rs b/src/commands/install.rs index e51ac0b8b..f571a0b68 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -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 { @@ -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)? @@ -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) @@ -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); @@ -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()); diff --git a/src/commands/ls_remote.rs b/src/commands/ls_remote.rs index 3da0dd922..76443d8aa 100644 --- a/src/commands/ls_remote.rs +++ b/src/commands/ls_remote.rs @@ -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 { diff --git a/src/config.rs b/src/config.rs index fbaf3a3af..44c86dfa9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + /// The root directory of fnm installations. #[clap( long = "fnm-dir", @@ -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, @@ -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, diff --git a/src/downloader.rs b/src/downloader.rs index 6fb6b6c25..7cad3daa9 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -73,6 +73,7 @@ pub fn install_node_dist>( 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()); @@ -93,7 +94,7 @@ pub fn install_node_dist>( 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; @@ -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"); diff --git a/src/http.rs b/src/http.rs index 57d23a3a1..0acf7a783 100644 --- a/src/http.rs +++ b/src/http.rs @@ -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)] @@ -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 { +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 { 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)); + } +} diff --git a/src/remote_node_index.rs b/src/remote_node_index.rs index 9c4016ac5..98e8950ec 100644 --- a/src/remote_node_index.rs +++ b/src/remote_node_index.rs @@ -81,10 +81,10 @@ pub enum Error { /// ```rust /// use crate::remote_node_index::list; /// ``` -pub fn list(base_url: &Url) -> Result, Error> { +pub fn list(base_url: &Url, auth_header: Option<&str>) -> Result, 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)?; @@ -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(..)