feat: upstream auth for all protocols (Docker, Maven, npm, PyPI)

Wire up basic auth credentials for upstream registry proxying:
- Docker: pass configured auth to Bearer token requests
- Maven: support url|auth format in NORA_MAVEN_PROXIES env var
- npm: add NORA_NPM_PROXY_AUTH env var
- PyPI: add NORA_PYPI_PROXY_AUTH env var
- Mask credentials in logs (never log plaintext passwords)

Config examples:
  NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"
  NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"
  NORA_NPM_PROXY_AUTH="user:pass"
  NORA_PYPI_PROXY_AUTH="user:pass"
This commit is contained in:
2026-03-15 21:29:20 +00:00
parent 028e98759a
commit e02e63a972
7 changed files with 186 additions and 59 deletions

View File

@@ -15,6 +15,7 @@ use axum::{
routing::{delete, get, head, patch, put},
Json, Router,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -181,6 +182,7 @@ async fn download_blob(
&digest,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -392,6 +394,7 @@ async fn get_manifest(
&reference,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream(
digest: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let url = format!(
"{}/v2/{}/blobs/{}",
@@ -741,13 +745,13 @@ async fn fetch_blob_from_upstream(
digest
);
// First try without auth
let response = client
.get(&url)
.timeout(Duration::from_secs(timeout))
.send()
.await
.map_err(|_| ())?;
// First try with basic auth if configured
let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
if let Some(credentials) = basic_auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token
@@ -758,7 +762,7 @@ async fn fetch_blob_from_upstream(
.map(String::from);
if let Some(token) = docker_auth
.get_token(upstream_url, name, www_auth.as_deref())
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
.await
{
client
@@ -790,6 +794,7 @@ async fn fetch_manifest_from_upstream(
reference: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<(Vec<u8>, String), ()> {
let url = format!(
"{}/v2/{}/manifests/{}",
@@ -806,16 +811,18 @@ async fn fetch_manifest_from_upstream(
application/vnd.oci.image.manifest.v1+json, \
application/vnd.oci.image.index.v1+json";
// First try without auth
let response = client
// First try with basic auth if configured
let mut request = client
.get(&url)
.timeout(Duration::from_secs(timeout))
.header("Accept", accept_header)
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
})?;
.header("Accept", accept_header);
if let Some(credentials) = basic_auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|e| {
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
})?;
tracing::debug!(status = %response.status(), "Initial upstream response");
@@ -830,7 +837,7 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
if let Some(token) = docker_auth
.get_token(upstream_url, name, www_auth.as_deref())
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
.await
{
tracing::debug!("Token acquired, retrying with auth");

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use base64::{engine::general_purpose::STANDARD, Engine};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::time::{Duration, Instant};
@@ -36,6 +37,7 @@ impl DockerAuth {
registry_url: &str,
name: &str,
www_authenticate: Option<&str>,
basic_auth: Option<&str>,
) -> Option<String> {
let cache_key = format!("{}:{}", registry_url, name);
@@ -51,7 +53,7 @@ impl DockerAuth {
// Need to fetch a new token
let www_auth = www_authenticate?;
let token = self.fetch_token(www_auth, name).await?;
let token = self.fetch_token(www_auth, name, basic_auth).await?;
// Cache the token (default 5 minute expiry)
{
@@ -70,7 +72,12 @@ impl DockerAuth {
/// Parse Www-Authenticate header and fetch token from auth server
/// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
async fn fetch_token(&self, www_authenticate: &str, name: &str) -> Option<String> {
async fn fetch_token(
&self,
www_authenticate: &str,
name: &str,
basic_auth: Option<&str>,
) -> Option<String> {
let params = parse_www_authenticate(www_authenticate)?;
let realm = params.get("realm")?;
@@ -82,7 +89,14 @@ impl DockerAuth {
tracing::debug!(url = %url, "Fetching auth token");
let response = self.client.get(&url).send().await.ok()?;
let mut request = self.client.get(&url);
if let Some(credentials) = basic_auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
tracing::debug!("Using basic auth for token request");
}
let response = request.send().await.ok()?;
if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Token request failed");
@@ -104,9 +118,15 @@ impl DockerAuth {
url: &str,
registry_url: &str,
name: &str,
basic_auth: Option<&str>,
) -> Result<reqwest::Response, ()> {
// First try without auth
let response = self.client.get(url).send().await.map_err(|_| ())?;
// First try with basic auth if configured, otherwise anonymous
let mut request = self.client.get(url);
if let Some(credentials) = basic_auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Extract Www-Authenticate header
@@ -118,7 +138,7 @@ impl DockerAuth {
// Get token and retry
if let Some(token) = self
.get_token(registry_url, name, www_auth.as_deref())
.get_token(registry_url, name, www_auth.as_deref(), basic_auth)
.await
{
return self

View File

@@ -12,6 +12,7 @@ use axum::{
routing::{get, put},
Router,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc;
use std::time::Duration;
@@ -49,10 +50,17 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
return with_content_type(&path, data).into_response();
}
for proxy_url in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
for proxy in &state.config.maven.proxies {
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
match fetch_from_proxy(&state.http_client, &url, state.config.maven.proxy_timeout).await {
match fetch_from_proxy(
&state.http_client,
&url,
state.config.maven.proxy_timeout,
proxy.auth(),
)
.await
{
Ok(data) => {
state.metrics.record_download("maven");
state.metrics.record_cache_miss();
@@ -124,13 +132,14 @@ async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|_| ())?;
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());

View File

@@ -12,6 +12,7 @@ use axum::{
routing::get,
Router,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc;
use std::time::Duration;
@@ -59,8 +60,13 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
if let Ok(data) =
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await
if let Ok(data) = fetch_from_proxy(
&state.http_client,
&url,
state.config.npm.proxy_timeout,
state.config.npm.proxy_auth.as_deref(),
)
.await
{
if is_tarball {
state.metrics.record_download("npm");
@@ -98,13 +104,14 @@ async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|_| ())?;
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());

View File

@@ -11,6 +11,7 @@ use axum::{
routing::get,
Router,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::Arc;
use std::time::Duration;
@@ -86,8 +87,13 @@ async fn package_versions(
if let Some(proxy_url) = &state.config.pypi.proxy {
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) =
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await
if let Ok(html) = fetch_package_page(
&state.http_client,
&url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
// Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized);
@@ -140,6 +146,7 @@ async fn download_file(
&state.http_client,
&page_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
@@ -149,6 +156,7 @@ async fn download_file(
&state.http_client,
&file_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
@@ -202,14 +210,17 @@ async fn fetch_package_page(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<String, ()> {
let response = client
let mut request = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html")
.send()
.await
.map_err(|_| ())?;
.header("Accept", "text/html");
if let Some(credentials) = auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
@@ -219,13 +230,18 @@ async fn fetch_package_page(
}
/// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|_| ())?;
async fn fetch_file(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
let encoded = STANDARD.encode(credentials);
request = request.header("Authorization", format!("Basic {}", encoded));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());