mod htpasswd;
mod namespace;
pub mod oidc;
mod token_routes;
pub use htpasswd::HtpasswdAuth;
pub use namespace::{enforce_namespace_scope, NamespaceAuthority};
pub use oidc::OidcValidator;
pub use token_routes::{token_routes, TokenListItem, TokenListResponse};
#[derive(Clone, Debug)]
pub struct AuthenticatedUser(pub String);
#[derive(Clone, Debug)]
pub struct AuthenticatedRole(pub crate::tokens::Role);
use axum::{
body::Body,
extract::{ConnectInfo, State},
http::{header, HeaderMap, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::time::Instant;
use crate::AppState;
pub struct AuthFailureTracker {
entries: parking_lot::Mutex<HashMap<IpAddr, (u32, Instant)>>,
max_failures: u32,
max_lockout_secs: u64,
}
impl AuthFailureTracker {
pub fn new(max_failures: u32, max_lockout_secs: u64) -> Self {
Self {
entries: parking_lot::Mutex::new(HashMap::new()),
max_failures,
max_lockout_secs,
}
}
pub fn check_blocked(&self, ip: &IpAddr) -> Option<u64> {
let entries = self.entries.lock();
let (failures, last_failure) = entries.get(ip)?;
if *failures < self.max_failures {
return None;
}
let exponent = (*failures - self.max_failures).min(20);
let lockout_secs = (1u64 << exponent).min(self.max_lockout_secs);
let elapsed = last_failure.elapsed().as_secs();
if elapsed < lockout_secs {
Some(lockout_secs - elapsed)
} else {
None
}
}
pub fn record_failure(&self, ip: IpAddr) {
let mut entries = self.entries.lock();
let entry = entries.entry(ip).or_insert_with(|| (0, Instant::now()));
entry.0 += 1;
entry.1 = Instant::now();
}
pub fn record_success(&self, ip: &IpAddr) {
let mut entries = self.entries.lock();
entries.remove(ip);
}
pub fn cleanup(&self) {
let mut entries = self.entries.lock();
entries.retain(|_, (_, last)| last.elapsed().as_secs() < self.max_lockout_secs * 2);
}
}
fn is_public_path(path: &str) -> bool {
matches!(
path,
"/" | "/health" | "/ready" | "/api/tokens" | "/api/tokens/list" | "/api/tokens/revoke"
)
}
fn is_web_surface(path: &str) -> bool {
if path.starts_with("/ui/tokens") || path.starts_with("/api/ui/tokens") {
return false;
}
path.starts_with("/ui") || path.starts_with("/api/ui") || path.starts_with("/api-docs")
}
fn is_docker_path(path: &str) -> bool {
path == "/v2" || path.starts_with("/v2/")
}
fn is_admin_path(path: &str) -> bool {
path.starts_with("/api/v1/admin/")
}
pub(crate) fn resolve_client_ip(
peer: IpAddr,
headers: &HeaderMap,
trusted_proxies: &crate::config::TrustedProxies,
) -> IpAddr {
if !trusted_proxies.contains(peer) {
return peer;
}
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(s) = xff.to_str() {
if let Some(first) = s.split(',').next() {
if let Ok(ip) = first.trim().parse::<IpAddr>() {
return ip;
}
}
}
}
if let Some(xri) = headers.get("x-real-ip") {
if let Ok(s) = xri.to_str() {
if let Ok(ip) = s.trim().parse::<IpAddr>() {
return ip;
}
}
}
peer
}
fn extract_client_ip(
request: &Request<Body>,
trusted_proxies: &crate::config::TrustedProxies,
) -> Option<IpAddr> {
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip())?;
Some(resolve_client_ip(peer, request.headers(), trusted_proxies))
}
async fn anonymous_read_passthrough(mut request: Request<Body>, next: Next) -> Response {
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request
.extensions_mut()
.insert(AuthenticatedUser("anonymous".to_string()));
request
.extensions_mut()
.insert(AuthenticatedRole(crate::tokens::Role::Read));
next.run(request).await
}
pub async fn auth_middleware(
State(state): State<AppState>,
mut request: Request<Body>,
next: Next,
) -> Response {
if !state.config.auth.enabled {
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request
.extensions_mut()
.insert(AuthenticatedUser("anonymous".to_string()));
return next.run(request).await;
}
{
let path = request.uri().path();
let config = &state.config.auth;
let open = is_public_path(path)
|| (is_web_surface(path) && (config.anonymous_read || config.public_web_ui))
|| (path == "/metrics" && config.public_metrics);
if open {
if let Some(auth_val) = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
{
if let Some(encoded) = auth_val.strip_prefix("Basic ") {
if let Some(username) = try_basic_auth(encoded, state.auth.as_deref()) {
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request.extensions_mut().insert(AuthenticatedUser(username));
request
.extensions_mut()
.insert(AuthenticatedRole(crate::tokens::Role::Write));
return next.run(request).await;
}
} else if let Some(token) = auth_val.strip_prefix("Bearer ") {
if let Some(ref token_store) = state.tokens {
if let Ok((user, role)) = token_store.verify_token(token) {
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request.extensions_mut().insert(AuthenticatedUser(user));
request.extensions_mut().insert(AuthenticatedRole(role));
return next.run(request).await;
}
}
}
}
let mut request = request;
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request
.extensions_mut()
.insert(AuthenticatedUser("anonymous".to_string()));
return next.run(request).await;
}
if (is_web_surface(path) || path == "/metrics")
&& request
.headers()
.get(axum::http::header::AUTHORIZATION)
.is_none()
{
return axum::http::Response::builder()
.status(axum::http::StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"nora\"")
.body(axum::body::Body::from("Authentication required"))
.expect("valid response");
}
}
let path = request.uri().path();
let is_docker = is_docker_path(path);
let is_docker_catalog = path == "/v2/_catalog";
let is_token_management = path.starts_with("/ui/tokens") || path.starts_with("/api/ui/tokens");
let is_whoami = path.ends_with("/-/whoami");
let is_admin = is_admin_path(path);
let is_read_method = matches!(
*request.method(),
axum::http::Method::GET | axum::http::Method::HEAD
);
let is_npm_audit = *request.method() == axum::http::Method::POST
&& (path == "/npm/-/npm/v1/security/advisories/bulk"
|| path == "/npm/-/npm/v1/security/audits/quick");
let has_auth_header = request.headers().contains_key(header::AUTHORIZATION);
if state.config.auth.anonymous_read
&& (is_read_method || is_npm_audit)
&& !is_docker
&& !is_token_management
&& !is_whoami
&& !is_admin
{
return anonymous_read_passthrough(request, next).await;
}
if state.config.auth.docker_anon_pull
&& is_read_method
&& is_docker
&& !is_docker_catalog
&& !has_auth_header
{
return anonymous_read_passthrough(request, next).await;
}
let realm = state.config.server.public_url.as_deref().unwrap_or("Nora");
let client_ip = extract_client_ip(&request, &state.config.auth.trusted_proxies);
if let Some(ip) = client_ip {
if let Some(retry_after) = state.auth_failures.check_blocked(&ip) {
return (
StatusCode::TOO_MANY_REQUESTS,
[
(header::RETRY_AFTER, retry_after.to_string()),
(header::CONTENT_TYPE, "application/json".to_string()),
],
format!(
r#"{{"error":"Too many failed attempts. Retry after {} seconds."}}"#,
retry_after
),
)
.into_response();
}
}
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let auth_header = match auth_header {
Some(h) => h,
None => return unauthorized_response("Authentication required", realm),
};
if let Some(token) = auth_header.strip_prefix("Bearer ") {
if let Some(ref token_store) = state.tokens {
match token_store.verify_token(token) {
Ok((user, role)) => {
if let Some(ip) = client_ip {
state.auth_failures.record_success(&ip);
}
let method = request.method().clone();
if (method == axum::http::Method::PUT
|| method == axum::http::Method::POST
|| method == axum::http::Method::DELETE
|| method == axum::http::Method::PATCH)
&& !role.can_write()
{
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
}
if is_admin && !role.can_admin() {
return (StatusCode::FORBIDDEN, "Admin role required").into_response();
}
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request.extensions_mut().insert(AuthenticatedUser(user));
request.extensions_mut().insert(AuthenticatedRole(role));
return next.run(request).await;
}
Err(crate::tokens::TokenError::Storage(e)) => {
tracing::error!(error = %e, "token store read failed during Bearer auth");
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token verification unavailable",
)
.into_response();
}
Err(_) => {
}
}
}
if let Some(ref oidc_validator) = state.oidc {
if oidc_validator.is_active() {
match oidc_validator.validate_token(token).await {
Ok(identity) => {
if let Some(ip) = client_ip {
state.auth_failures.record_success(&ip);
}
tracing::debug!(
provider = %identity.provider,
subject = %identity.subject,
role = ?identity.role,
"OIDC authentication successful"
);
let method = request.method().clone();
if (method == axum::http::Method::PUT
|| method == axum::http::Method::POST
|| method == axum::http::Method::DELETE
|| method == axum::http::Method::PATCH)
&& !identity.role.can_write()
{
return (StatusCode::FORBIDDEN, "Read-only OIDC identity")
.into_response();
}
if is_admin && !identity.role.can_admin() {
return (StatusCode::FORBIDDEN, "Admin role required").into_response();
}
let authority = NamespaceAuthority::from_oidc_scopes(
&identity.provider,
std::iter::once(identity.namespace_scope.as_slice())
.chain(identity.rule_namespace_scope.as_deref()),
identity.namespace_scope_enforcement,
);
request.extensions_mut().insert(authority);
request
.extensions_mut()
.insert(AuthenticatedUser(identity.subject.clone()));
request
.extensions_mut()
.insert(AuthenticatedRole(identity.role));
return next.run(request).await;
}
Err(_) => {
}
}
}
}
if let Some(ip) = client_ip {
state.auth_failures.record_failure(ip);
}
return unauthorized_response("Invalid or expired token", realm);
}
if !auth_header.starts_with("Basic ") {
return unauthorized_response("Basic or Bearer authentication required", realm);
}
let auth = match &state.auth {
Some(auth) => auth,
None => return unauthorized_response("Basic auth not configured", realm),
};
let encoded = &auth_header[6..];
let decoded = match STANDARD.decode(encoded) {
Ok(d) => d,
Err(_) => return unauthorized_response("Invalid credentials encoding", realm),
};
let credentials = match String::from_utf8(decoded) {
Ok(c) => c,
Err(_) => return unauthorized_response("Invalid credentials encoding", realm),
};
let (username, password) = match credentials.split_once(':') {
Some((u, p)) => (u, p),
None => return unauthorized_response("Invalid credentials format", realm),
};
if !auth.authenticate(username, password) {
let token_result = state.tokens.as_ref().map(|ts| ts.verify_token(password));
if let Some(Err(crate::tokens::TokenError::Storage(ref e))) = token_result {
tracing::error!(error = %e, "token store read failed during Basic auth fallback");
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token verification unavailable",
)
.into_response();
}
if let Some(Ok((token_user, role))) = token_result {
if let Some(ip) = client_ip {
state.auth_failures.record_success(&ip);
}
let method = request.method().clone();
if (method == axum::http::Method::PUT
|| method == axum::http::Method::POST
|| method == axum::http::Method::DELETE
|| method == axum::http::Method::PATCH)
&& !role.can_write()
{
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
}
if is_admin && !role.can_admin() {
return (StatusCode::FORBIDDEN, "Admin role required").into_response();
}
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request
.extensions_mut()
.insert(AuthenticatedUser(token_user));
request.extensions_mut().insert(AuthenticatedRole(role));
return next.run(request).await;
}
if let Some(ip) = client_ip {
state.auth_failures.record_failure(ip);
}
return unauthorized_response("Invalid username or password", realm);
}
if let Some(ip) = client_ip {
state.auth_failures.record_success(&ip);
}
if is_admin {
return (StatusCode::FORBIDDEN, "Admin role required").into_response();
}
request
.extensions_mut()
.insert(NamespaceAuthority::Unrestricted);
request
.extensions_mut()
.insert(AuthenticatedUser(username.to_string()));
request
.extensions_mut()
.insert(AuthenticatedRole(crate::tokens::Role::Write));
next.run(request).await
}
fn try_basic_auth(encoded: &str, auth: Option<&HtpasswdAuth>) -> Option<String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let decoded = String::from_utf8(STANDARD.decode(encoded).ok()?).ok()?;
let (username, password) = decoded.split_once(':')?;
let htpasswd = auth?;
if htpasswd.authenticate(username, password) {
Some(username.to_string())
} else {
None
}
}
fn unauthorized_response(message: &str, realm: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
[
(
header::WWW_AUTHENTICATE,
format!("Basic realm=\"{}\"", realm),
),
(header::CONTENT_TYPE, "application/json".to_string()),
],
format!(r#"{{"error":"{}"}}"#, message),
)
.into_response()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_public_path_classification() {
for p in [
"/",
"/health",
"/ready",
"/api/tokens",
"/api/tokens/list",
"/api/tokens/revoke",
] {
assert!(is_public_path(p), "{p}");
}
for p in [
"/ui",
"/ui/dashboard",
"/api-docs",
"/api-docs/openapi.json",
"/api/ui/stats",
"/metrics",
] {
assert!(!is_public_path(p), "{p}");
}
for p in ["/ui", "/ui/rpm", "/api/ui/stats", "/api-docs"] {
assert!(is_web_surface(p), "{p}");
}
assert!(!is_web_surface("/ui/tokens"));
assert!(!is_web_surface("/api/ui/tokens"));
assert!(!is_public_path("/v2/"));
assert!(!is_web_surface("/v2/"));
}
#[test]
fn test_is_public_path_health() {
assert!(is_public_path("/health"));
assert!(is_public_path("/ready"));
assert!(!is_public_path("/metrics"));
}
#[test]
fn test_v2_is_not_public_path() {
assert!(!is_public_path("/v2/"));
assert!(!is_public_path("/v2"));
assert!(is_docker_path("/v2/"));
assert!(is_docker_path("/v2"));
assert!(is_docker_path("/v2/alpine/manifests/latest"));
assert!(!is_docker_path("/raw/file.txt"));
assert!(!is_docker_path("/v2x/sneaky"));
}
#[test]
fn test_is_public_path_ui() {
for p in ["/ui", "/ui/dashboard", "/ui/repos"] {
assert!(!is_public_path(p), "{p}");
assert!(is_web_surface(p), "{p}");
}
}
#[test]
fn test_is_public_path_api_docs() {
for p in ["/api-docs", "/api-docs/openapi.json", "/api/ui"] {
assert!(!is_public_path(p), "{p}");
assert!(is_web_surface(p), "{p}");
}
}
#[test]
fn test_is_public_path_tokens() {
assert!(is_public_path("/api/tokens"));
assert!(is_public_path("/api/tokens/list"));
assert!(is_public_path("/api/tokens/revoke"));
}
#[test]
fn test_is_public_path_root() {
assert!(is_public_path("/"));
}
#[test]
fn test_is_not_public_path_registry() {
assert!(!is_public_path("/v2/library/alpine/manifests/latest"));
assert!(!is_public_path("/npm/lodash"));
assert!(!is_public_path("/maven/com/example"));
assert!(!is_public_path("/pypi/simple/flask"));
}
#[test]
fn test_is_not_public_path_random() {
assert!(!is_public_path("/admin"));
assert!(!is_public_path("/secret"));
assert!(!is_public_path("/api/data"));
}
#[test]
fn test_token_ui_paths_not_public() {
assert!(!is_public_path("/ui/tokens"));
assert!(!is_public_path("/ui/tokens/"));
assert!(!is_public_path("/api/ui/tokens/create"));
assert!(!is_public_path("/api/ui/tokens/list"));
assert!(!is_public_path("/api/ui/tokens/abcd1234abcd1234/revoke"));
}
#[test]
fn test_xff_trusted_proxy_uses_forwarded_ip() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::parse("127.0.0.1,::1");
let mut request = Request::builder()
.uri("/test")
.header("x-forwarded-for", "1.2.3.4, 127.0.0.1")
.body(Body::empty())
.unwrap();
request.extensions_mut().insert(ConnectInfo(SocketAddr::new(
"127.0.0.1".parse().unwrap(),
1234,
)));
let ip = extract_client_ip(&request, &proxies);
assert_eq!(ip, Some("1.2.3.4".parse().unwrap()));
}
#[test]
fn test_xff_untrusted_proxy_uses_peer_ip() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::parse("127.0.0.1,::1");
let mut request = Request::builder()
.uri("/test")
.header("x-forwarded-for", "1.2.3.4")
.body(Body::empty())
.unwrap();
request.extensions_mut().insert(ConnectInfo(SocketAddr::new(
"5.6.7.8".parse().unwrap(),
1234,
)));
let ip = extract_client_ip(&request, &proxies);
assert_eq!(ip, Some("5.6.7.8".parse().unwrap()));
}
#[test]
fn test_xff_no_header_uses_peer_ip() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::parse("127.0.0.1,::1");
let mut request = Request::builder().uri("/test").body(Body::empty()).unwrap();
request.extensions_mut().insert(ConnectInfo(SocketAddr::new(
"127.0.0.1".parse().unwrap(),
1234,
)));
let ip = extract_client_ip(&request, &proxies);
assert_eq!(ip, Some("127.0.0.1".parse().unwrap()));
}
#[test]
fn test_trusted_proxies_parse_cidr() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::parse("10.0.0.0/8");
assert!(proxies.contains("10.1.2.3".parse().unwrap()));
assert!(proxies.contains("10.255.255.255".parse().unwrap()));
assert!(!proxies.contains("11.0.0.1".parse().unwrap()));
}
#[test]
fn test_trusted_proxies_parse_single_ip() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::parse("192.168.1.1");
assert!(proxies.contains("192.168.1.1".parse().unwrap()));
assert!(!proxies.contains("192.168.1.2".parse().unwrap()));
}
#[test]
fn test_trusted_proxies_default_loopback() {
use crate::config::TrustedProxies;
let proxies = TrustedProxies::default_loopback();
assert!(proxies.contains("127.0.0.1".parse().unwrap()));
assert!(proxies.contains("::1".parse().unwrap()));
assert!(!proxies.contains("10.0.0.1".parse().unwrap()));
}
#[test]
fn test_auth_failure_tracker_allows_under_threshold() {
let tracker = AuthFailureTracker::new(5, 900);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
for _ in 0..4 {
tracker.record_failure(ip);
}
assert!(tracker.check_blocked(&ip).is_none());
}
#[test]
fn test_auth_failure_tracker_blocks_at_threshold() {
let tracker = AuthFailureTracker::new(5, 900);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
for _ in 0..5 {
tracker.record_failure(ip);
}
assert!(tracker.check_blocked(&ip).is_some());
}
#[test]
fn test_auth_failure_tracker_success_clears() {
let tracker = AuthFailureTracker::new(5, 900);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
for _ in 0..10 {
tracker.record_failure(ip);
}
assert!(tracker.check_blocked(&ip).is_some());
tracker.record_success(&ip);
assert!(tracker.check_blocked(&ip).is_none());
}
#[test]
fn test_auth_failure_tracker_independent_ips() {
let tracker = AuthFailureTracker::new(3, 900);
let ip1: IpAddr = "10.0.0.1".parse().unwrap();
let ip2: IpAddr = "10.0.0.2".parse().unwrap();
for _ in 0..3 {
tracker.record_failure(ip1);
}
assert!(tracker.check_blocked(&ip1).is_some());
assert!(tracker.check_blocked(&ip2).is_none());
}
#[test]
fn test_auth_failure_tracker_cleanup() {
let tracker = AuthFailureTracker::new(3, 1); let ip: IpAddr = "10.0.0.1".parse().unwrap();
for _ in 0..5 {
tracker.record_failure(ip);
}
std::thread::sleep(std::time::Duration::from_secs(3));
tracker.cleanup();
assert!(tracker.check_blocked(&ip).is_none());
}
#[test]
fn test_auth_failure_tracker_exponential_backoff() {
let tracker = AuthFailureTracker::new(5, 900);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
for _ in 0..5 {
tracker.record_failure(ip);
}
let retry1 = tracker.check_blocked(&ip).unwrap();
assert!(
retry1 <= 1,
"first lockout should be ~1 sec, got {}",
retry1
);
tracker.record_failure(ip);
let retry2 = tracker.check_blocked(&ip).unwrap();
assert!(
retry2 <= 2,
"second lockout should be ~2 sec, got {}",
retry2
);
tracker.record_failure(ip);
let retry3 = tracker.check_blocked(&ip).unwrap();
assert!(
retry3 <= 4,
"third lockout should be ~4 sec, got {}",
retry3
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use crate::test_helpers::*;
use axum::http::{Method, StatusCode};
use base64::{engine::general_purpose::STANDARD, Engine};
#[tokio::test]
async fn test_auth_disabled_passes_all() {
let ctx = create_test_context();
let response = send(&ctx.app, Method::PUT, "/raw/test.txt", b"data".to_vec()).await;
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_auth_public_paths_always_pass() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/health", "").await;
assert_eq!(response.status(), StatusCode::OK);
let response = send(&ctx.app, Method::GET, "/ready", "").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_docker_v2_requires_auth_when_enabled() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.headers().contains_key("www-authenticate"));
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/v2/",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_docker_v2_ignores_anonymous_read() {
let ctx = create_test_context_with_anonymous_read(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.headers().contains_key("www-authenticate"));
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let response = send(&ctx.app, Method::GET, "/raw/test.txt", "").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_docker_anon_pull_allows_v2_ping() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/", "").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_docker_anon_pull_allows_manifest_read() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/alpine/manifests/latest", "").await;
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"anonymous manifest read must pass the auth gate under docker_anon_pull"
);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_docker_anon_pull_blocks_anonymous_writes() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let response = send(&ctx.app, Method::POST, "/v2/alpine/blobs/uploads/", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = send(
&ctx.app,
Method::PUT,
"/v2/alpine/manifests/latest",
b"{}".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = send(
&ctx.app,
Method::DELETE,
"/v2/alpine/manifests/sha256:0000000000000000000000000000000000000000000000000000000000000000",
"",
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_docker_anon_pull_authenticated_push_still_works() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::POST,
"/v2/alpine/blobs/uploads/",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn test_docker_anon_pull_catalog_requires_auth() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/_catalog", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/v2/_catalog",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_docker_anon_pull_validates_presented_credentials() {
let ctx = create_test_context_with_docker_anon_pull(&[("admin", "secret")]);
let bad = format!("Basic {}", STANDARD.encode("admin:wrong"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/v2/",
vec![("authorization", &bad)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let good = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/v2/",
vec![("authorization", &good)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_anonymous_read_does_not_open_docker() {
let ctx = create_test_context_with_anonymous_read(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/v2/", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = send(&ctx.app, Method::GET, "/v2/alpine/manifests/latest", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_docker_v2_passes_when_auth_disabled() {
let ctx = create_test_context();
let response = send(&ctx.app, Method::GET, "/v2/", "").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_auth_blocks_without_credentials() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send(&ctx.app, Method::PUT, "/raw/test.txt", b"data".to_vec()).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.headers().contains_key("www-authenticate"));
}
#[tokio::test]
async fn test_auth_basic_works() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_auth_basic_wrong_password() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:wrong"));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_basic_auth_accepts_api_token() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let token = ctx
.state
.tokens
.as_ref()
.unwrap()
.create_token("admin", 30, None, crate::tokens::Role::Write)
.unwrap();
let header_val = format!("Basic {}", STANDARD.encode(format!("token:{token}")));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_basic_auth_read_only_token_cannot_write() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let token = ctx
.state
.tokens
.as_ref()
.unwrap()
.create_token("admin", 30, None, crate::tokens::Role::Read)
.unwrap();
let header_val = format!("Basic {}", STANDARD.encode(format!("token:{token}")));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
fn token_file_path(ctx: &crate::test_helpers::TestContext, token: &str) -> std::path::PathBuf {
use sha2::Digest;
let prefix = hex::encode(sha2::Sha256::digest(token.as_bytes()));
ctx._tempdir
.path()
.join("tokens")
.join(format!("{}.json", &prefix[..16]))
}
#[tokio::test]
async fn test_basic_auth_token_store_error_returns_503_not_401() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let token = ctx
.state
.tokens
.as_ref()
.unwrap()
.create_token("ci", 30, None, crate::tokens::Role::Read)
.unwrap();
std::fs::write(token_file_path(&ctx, &token), "{\"token_ha").unwrap();
let header_val = format!("Basic {}", STANDARD.encode(format!("ci:{token}")));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &header_val)],
Vec::new(),
)
.await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_bearer_token_store_error_returns_503_not_401() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let token = ctx
.state
.tokens
.as_ref()
.unwrap()
.create_token("ci", 30, None, crate::tokens::Role::Read)
.unwrap();
std::fs::write(token_file_path(&ctx, &token), "{\"token_ha").unwrap();
let header_val = format!("Bearer {token}");
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &header_val)],
Vec::new(),
)
.await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_auth_anonymous_read() {
let ctx = create_test_context_with_anonymous_read(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/test.txt",
vec![("authorization", &header_val)],
b"data".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let response = send(&ctx.app, Method::GET, "/raw/test.txt", "").await;
assert_eq!(response.status(), StatusCode::OK);
let response = send(&ctx.app, Method::PUT, "/raw/test2.txt", b"data".to_vec()).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_token_ui_requires_auth_with_anonymous_read() {
let ctx = create_test_context_with_anonymous_read(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/ui/tokens", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = send(&ctx.app, Method::GET, "/api/ui/tokens/list", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/ui/tokens",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let response = send(&ctx.app, Method::GET, "/health", "").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_token_ui_requires_auth() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send(&ctx.app, Method::GET, "/ui/tokens", "").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::GET,
"/ui/tokens",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_token_ui_create_requires_htmx() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/ui/tokens/create",
vec![
("authorization", &header_val),
("content-type", "application/x-www-form-urlencoded"),
],
"description=test&role=read&ttl_days=30",
)
.await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/ui/tokens/create",
vec![
("authorization", &header_val),
("content-type", "application/x-www-form-urlencoded"),
("hx-request", "true"),
],
"description=test&role=read&ttl_days=30",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_token_ui_revoke_validates_file_id() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/ui/tokens/not_valid_hex_xx/revoke",
vec![("authorization", &header_val), ("hx-request", "true")],
"",
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/ui/tokens/abcd1234abcd1234/revoke",
vec![("authorization", &header_val), ("hx-request", "true")],
"",
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_token_create_without_credentials_returns_401() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/tokens",
vec![("content-type", "application/json")],
r#"{}"#,
)
.await;
assert!(
response.status() == StatusCode::UNAUTHORIZED
|| response.status() == StatusCode::UNPROCESSABLE_ENTITY
|| response.status() == StatusCode::BAD_REQUEST,
"Expected 401/422/400, got {}",
response.status()
);
}
#[tokio::test]
async fn test_token_list_without_credentials_returns_401() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/tokens/list",
vec![("content-type", "application/json")],
r#"{}"#,
)
.await;
assert!(
response.status() == StatusCode::UNAUTHORIZED
|| response.status() == StatusCode::UNPROCESSABLE_ENTITY
|| response.status() == StatusCode::BAD_REQUEST,
"Expected 401/422/400, got {}",
response.status()
);
}
#[tokio::test]
async fn test_token_revoke_without_credentials_returns_401() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/tokens/revoke",
vec![("content-type", "application/json")],
r#"{}"#,
)
.await;
assert!(
response.status() == StatusCode::UNAUTHORIZED
|| response.status() == StatusCode::UNPROCESSABLE_ENTITY
|| response.status() == StatusCode::BAD_REQUEST,
"Expected 401/422/400, got {}",
response.status()
);
}
#[tokio::test]
async fn test_token_ui_full_lifecycle() {
let ctx = create_test_context_with_auth(&[("admin", "secret")]);
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let response = send_with_headers(
&ctx.app,
Method::POST,
"/api/ui/tokens/create",
vec![
("authorization", &header_val),
("content-type", "application/x-www-form-urlencoded"),
("hx-request", "true"),
],
"description=CI+Pipeline&role=write&ttl_days=30",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(body_bytes(response).await.to_vec()).unwrap();
assert!(body.contains("nra_"), "Response should contain raw token");
let response = send_with_headers(
&ctx.app,
Method::GET,
"/api/ui/tokens/list",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = String::from_utf8(body_bytes(response).await.to_vec()).unwrap();
assert!(body.contains("CI Pipeline"), "List should show description");
let tokens = ctx.state.tokens.as_ref().unwrap().list_all_tokens();
assert_eq!(tokens.len(), 1);
let file_id = &tokens[0].file_id;
let revoke_url = format!("/api/ui/tokens/{}/revoke", file_id);
let response = send_with_headers(
&ctx.app,
Method::POST,
&revoke_url,
vec![("authorization", &header_val), ("hx-request", "true")],
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let tokens = ctx.state.tokens.as_ref().unwrap().list_all_tokens();
assert_eq!(tokens.len(), 0);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod oidc_integration_tests {
use crate::auth::oidc::OidcValidator;
use crate::config::{OidcConfig, OidcProvider, OidcRoleRule};
use crate::test_helpers::*;
use axum::http::{Method, StatusCode};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde_json::json;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_RSA_PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7657FVL7gwmjj
PEfl4A+ajG3DFj6YHmS9gargHMChpdLMbt8ybqu1hHPUNKQyndUvFJ6q+xJFEds3
eBtjB5GLLtlj9lScvGPsV7386rypeHq30IErgm2beQJyF9ldtrVHBBPbz7eAo4+i
wCJ/m5IsuoLCYZPQrDUdpax0dUEa/eP6badqjZ2r0recnHw1+zGyozzSHNvPtK9I
PsKwBcbGjt5n5+9nWN322/mISAuLNwtv7l3Lja8U7m0ixH0ZLwFSgTLtzEfJISux
+ngGR4k2PBVeo+yDOZtuatx7Aixa1FerOwiq2xsoGOs2dhXagwFGdwbs8x/MvEj0
67Mm3OrLAgMBAAECggEAHXZkjyWpQ43XagESeKz3ZVCtCNAdAjaJrth8lOSNIwrf
kOO1JLALRcs9acDTGYh7WwVNlxsEE0Yoa3ruOEmAfSTcOnrtayFyPSTIibW33I4i
F12eUtcBHkYLpx2sG7BAnaC7CFR5vbZnF6ot/nnCojaft6Aaz7WgIkTOU/fqPDPb
WOSn4PQmgZS34non7y0NWmxxeIwqJk3aBeeEKisO2AS0YCHOgx2uBTIt2lcIzjK8
RHwQjLRRfhzxuhHuQtz/hMVQ17W3l7ehYTnW0D+UJJTXBjPgElICmWdGm9NMX9YH
HzVSBdH+tzTZ/hUhKe+nEZ5vrWT2wqx/h0med2P3eQKBgQDYNURqK7dfo2QBsy/F
pdUg7UaXfWe+c6guu32aZhnxYsHUE68cuV7Bz/awjMJYvF9VKhoJ+iuHI0myo9In
HITzbSDwFrWCme7DAIPbfbQU9nQqJLK/g3nUpYSpjFQEnIPJSr1aS/fVpUURAoBg
RSktyRTY3ak7+6x1I54HLxPk3QKBgQDegZEqK6B28fQklCPgdimwnNr92oJe5hoY
9cHUDz3A1Uyek40LQ1yR7W/imDCJMcQXqM7Lo54+55eHEkBvh6H/TTmnGMzj5L7t
HoKYMjYdBK7waFYGM6ULfVXqs6JqVmKFU7LX+ZVmOB5kgcQMQrAhio0GrG97iDqz
aKHqOthfxwKBgQDKa+SnulIumlzRMqAxXfdSopOK1YBB0SrOxf7shVcYpitukRdL
v0m2DyyZUs/KIGLo60gBu1TxatpfA/2HXK4k8jD6V2iM4+2kaGELKH9neO59Xmpz
33Y63tR7oMQwpRDFbtIlLibUwa0OJddnSpkpIq//8le1rwVhjn0voKXxiQKBgQDS
2qPO+6LHtQewdjX9atydAjfAooYzGgkXKCTzKTJS/47pI1hgmQgrPX9uktxD1sZF
yXGWpsm6QMtmc5ReXIDWp77/q0/WkpmfqO8G/WYsX5jMN4N1wxEfbzmw/WPnM0+P
mz56zoiWYo3intpC6Bty3ZJBBb1rqjA+feQaTINpVwKBgF/M0Lj9Sq9G2Ec7yBnm
xhBlLwCNzAk33Fy+6w6ANXTsGRwMm0zGdTjC3e6LHMrD0ZtF0M2blWAUh3sZ6ItQ
2Ak5ScO0q3MRQvo4HZkFK2wuZvNLYExq6gGy3P6l8xXbvQTzg5nl9UWDKfY1gifz
Jd74nq6dNCjpWG4drIsyhqX+
-----END PRIVATE KEY-----"#;
const TEST_JWKS_JSON: &str = r#"{"keys":[{"kty":"RSA","kid":"test-key-1","use":"sig","alg":"RS256","n":"u-uexVS-4MJo4zxH5eAPmoxtwxY-mB5kvYGq4BzAoaXSzG7fMm6rtYRz1DSkMp3VLxSeqvsSRRHbN3gbYweRiy7ZY_ZUnLxj7Fe9_Oq8qXh6t9CBK4Jtm3kCchfZXba1RwQT28-3gKOPosAif5uSLLqCwmGT0Kw1HaWsdHVBGv3j-m2nao2dq9K3nJx8NfsxsqM80hzbz7SvSD7CsAXGxo7eZ-fvZ1jd9tv5iEgLizcLb-5dy42vFO5tIsR9GS8BUoEy7cxHySErsfp4BkeJNjwVXqPsgzmbbmrcewIsWtRXqzsIqtsbKBjrNnYV2oMBRncG7PMfzLxI9OuzJtzqyw","e":"AQAB"}]}"#;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn make_jwt(issuer: &str, subject: &str, audience: &str, iat: u64, exp: u64) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some("test-key-1".to_string());
let claims = json!({
"iss": issuer,
"sub": subject,
"aud": audience,
"iat": iat,
"exp": exp,
});
let key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY.as_bytes()).unwrap();
encode(&header, &claims, &key).unwrap()
}
fn create_oidc_test_context(mock_issuer_url: &str) -> TestContext {
create_oidc_test_context_scoped(mock_issuer_url, &["*"], None)
}
fn create_oidc_test_context_scoped(
mock_issuer_url: &str,
provider_scope: &[&str],
main_rule_scope: Option<Vec<String>>,
) -> TestContext {
let issuer = mock_issuer_url.to_string();
let provider_scope: Vec<String> = provider_scope.iter().map(|s| s.to_string()).collect();
let mut ctx = create_test_context_with_config(move |cfg| {
cfg.auth.enabled = true;
cfg.auth.anonymous_read = false;
cfg.auth.oidc = OidcConfig {
enabled: true,
leeway_secs: 60,
jwks_cache_secs: 300,
providers: vec![OidcProvider {
name: "test-ci".to_string(),
issuer: issuer.clone(),
jwks_uri: None,
audience: "nora".to_string(),
algorithms: vec!["RS256".to_string()],
max_token_lifetime_secs: 900,
namespace_scope: provider_scope.clone(),
namespace_scope_enforcement: crate::config::ScopeEnforcement::Enforce,
enabled: true,
role_rules: vec![
OidcRoleRule {
pattern: "repo:myorg/*:ref:refs/heads/main".to_string(),
role: "write".to_string(),
namespace_scope: main_rule_scope.clone(),
},
OidcRoleRule {
pattern: "repo:myorg/*:pull_request".to_string(),
role: "write".to_string(),
namespace_scope: Some(vec!["ci-transport/**".to_string()]),
},
OidcRoleRule {
pattern: "repo:myorg/*".to_string(),
role: "read".to_string(),
namespace_scope: None,
},
],
}],
};
});
let oidc_validator =
OidcValidator::new(ctx.state.config.auth.oidc.clone(), reqwest::Client::new());
let state = crate::AppState {
storage: ctx.state.storage.clone(),
config: ctx.state.config.clone(),
enabled_registries: ctx.state.enabled_registries.clone(),
start_time: ctx.state.start_time,
startup_duration_ms: ctx.state.startup_duration_ms,
auth: ctx.state.auth.clone(),
tokens: ctx.state.tokens.clone(),
metrics: Arc::new(crate::dashboard_metrics::DashboardMetrics::new()),
activity: Arc::new(crate::activity_log::ActivityLog::new(50)),
audit: ctx.state.audit.clone(),
docker_auth: Arc::new(crate::registry::DockerAuth::new(reqwest::Client::new(), 5)),
repo_index: Arc::new(crate::repo_index::RepoIndex::new()),
http_client: reqwest::Client::new(),
upload_sessions: Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new())),
publish_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())),
reloadable: Arc::new(arc_swap::ArcSwap::from_pointee(crate::ReloadableConfig {
curation_engine: crate::curation::CurationEngine::new(
crate::config::CurationConfig::default(),
),
bypass_token: None,
})),
auth_failures: Arc::new(crate::auth::AuthFailureTracker::new(5, 900)),
oidc: Some(Arc::new(oidc_validator)),
circuit_breaker: Arc::new(crate::circuit_breaker::CircuitBreakerRegistry::new(
ctx.state.config.circuit_breaker.clone(),
)),
proxy_coalesce: crate::proxy_coalesce::InflightMap::new(),
digest_store: ctx.state.digest_store.clone(),
signer: ctx.state.signer.clone(),
leak_finders: ctx.state.leak_finders.clone(),
cancel_token: tokio_util::sync::CancellationToken::new(),
};
use axum::{extract::DefaultBodyLimit, middleware, Router};
let mut registry_routes = Router::new();
for reg in state.enabled_registries.iter() {
match reg {
crate::registry_type::RegistryType::Raw => {
registry_routes = registry_routes.merge(crate::registry::raw_routes());
}
_ => {}
}
}
let public_routes = Router::new().merge(crate::health::routes());
let app_routes = Router::new()
.merge(crate::auth::token_routes())
.merge(crate::ui::routes())
.merge(registry_routes);
let app = Router::new()
.merge(public_routes)
.merge(app_routes)
.layer(DefaultBodyLimit::max(
state.config.server.body_limit_mb * 1024 * 1024,
))
.layer(middleware::from_fn(
crate::request_id::request_id_middleware,
))
.layer(middleware::from_fn_with_state(
state.clone(),
crate::auth::auth_middleware,
))
.with_state(state.clone());
ctx.state = state;
ctx.app = app;
ctx
}
#[tokio::test]
async fn test_oidc_valid_jwt_write_access() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/oidc-test.txt",
vec![("authorization", &bearer)],
b"hello from ci".to_vec(),
)
.await;
assert_eq!(
response.status(),
StatusCode::CREATED,
"Write with main-branch OIDC token should succeed"
);
}
#[tokio::test]
async fn test_oidc_rule_scope_narrows_writes() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:pull_request",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/ci-transport/run-1/artifact.txt",
vec![("authorization", &bearer)],
b"transport".to_vec(),
)
.await;
assert_eq!(
response.status(),
StatusCode::CREATED,
"PR token should write inside its rule scope"
);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/prod/artifact.txt",
vec![("authorization", &bearer)],
b"escape".to_vec(),
)
.await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"PR token must not write outside its rule scope"
);
}
#[tokio::test]
async fn test_oidc_rule_scope_cannot_widen_provider_ceiling() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context_scoped(
&mock_server.uri(),
&["myorg/**"],
Some(vec!["*".to_string()]),
);
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/myorg/repo/artifact.txt",
vec![("authorization", &bearer)],
b"inside".to_vec(),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/other/artifact.txt",
vec![("authorization", &bearer)],
b"escape".to_vec(),
)
.await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"a rule namespace_scope of [\"*\"] must not widen past the provider scope"
);
}
#[tokio::test]
async fn test_oidc_valid_jwt_read_only_blocks_write() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/dev",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::PUT,
"/raw/oidc-test.txt",
vec![("authorization", &bearer)],
b"hello".to_vec(),
)
.await;
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"Write with read-only OIDC token should be forbidden"
);
}
#[tokio::test]
async fn test_oidc_valid_jwt_read_only_allows_get() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/dev",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/nonexistent.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_ne!(
response.status(),
StatusCode::UNAUTHORIZED,
"Read with valid OIDC token should not be 401"
);
assert_ne!(
response.status(),
StatusCode::FORBIDDEN,
"Read with read-only OIDC token should not be 403"
);
}
#[tokio::test]
async fn test_oidc_expired_token_rejected() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"nora",
now - 600,
now - 120,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Expired OIDC token should be rejected"
);
}
#[tokio::test]
async fn test_oidc_wrong_issuer_rejected() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
"https://evil-issuer.example.com",
"repo:myorg/app:ref:refs/heads/main",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Token with wrong issuer should be rejected"
);
}
#[tokio::test]
async fn test_oidc_wrong_audience_rejected() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"wrong-audience",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Token with wrong audience should be rejected"
);
}
#[tokio::test]
async fn test_oidc_no_matching_role_rejected() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:otherorg/app:ref:refs/heads/main",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Token with no matching role should be rejected"
);
}
#[tokio::test]
async fn test_oidc_token_lifetime_exceeded() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"nora",
now,
now + 2000,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Token exceeding max lifetime should be rejected"
);
}
#[tokio::test]
async fn test_oidc_jwks_fetch_failure_returns_401() {
let mock_server = MockServer::start().await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let token = make_jwt(
&mock_server.uri(),
"repo:myorg/app:ref:refs/heads/main",
"nora",
now,
now + 600,
);
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Should fail gracefully when JWKS cannot be fetched"
);
}
#[tokio::test]
async fn test_oidc_symmetric_algorithm_rejected() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jwks.json"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TEST_JWKS_JSON, "application/json"),
)
.mount(&mock_server)
.await;
let ctx = create_oidc_test_context(&mock_server.uri());
let now = now_secs();
let mut header = Header::new(Algorithm::HS256);
header.kid = Some("test-key-1".to_string());
let claims = json!({
"iss": mock_server.uri(),
"sub": "repo:myorg/app:ref:refs/heads/main",
"aud": "nora",
"iat": now,
"exp": now + 600,
});
let key = EncodingKey::from_secret(b"fake-secret");
let token = encode(&header, &claims, &key).unwrap();
let bearer = format!("Bearer {}", token);
let response = send_with_headers(
&ctx.app,
Method::GET,
"/raw/test.txt",
vec![("authorization", &bearer)],
"",
)
.await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"HS256 tokens must be rejected for OIDC"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod web_surface_gating_tests {
use crate::test_helpers::{
body_bytes, create_test_context_with_anonymous_read,
create_test_context_with_anonymous_read_and_config, create_test_context_with_auth, send,
send_with_headers,
};
use axum::http::{Method, StatusCode};
use base64::{engine::general_purpose::STANDARD, Engine};
fn basic(user: &str, pass: &str) -> String {
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
)
}
#[tokio::test]
async fn test_web_surface_gated_when_private() {
let ctx = create_test_context_with_auth(&[("alice", "pw")]);
for p in [
"/ui/",
"/ui/rpm",
"/api/ui/stats",
"/api/ui/dashboard",
"/api-docs/openapi.json",
] {
let resp = send(&ctx.app, Method::GET, p, "").await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{p}");
assert!(
resp.headers().get("www-authenticate").is_some(),
"{p} must challenge so browsers prompt"
);
}
for p in ["/health", "/ready"] {
let resp = send(&ctx.app, Method::GET, p, "").await;
assert_eq!(resp.status(), StatusCode::OK, "{p}");
}
let resp = send(&ctx.app, Method::GET, "/metrics", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let cred = basic("alice", "pw");
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/api/ui/stats",
vec![("authorization", cred.as_str())],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_gating_switches() {
let ctx = crate::test_helpers::create_test_context_with_config(|c| {
c.auth.enabled = true;
c.auth.anonymous_read = true;
});
let resp = send(&ctx.app, Method::GET, "/ui/", "").await;
assert_eq!(resp.status(), StatusCode::OK, "anonymous_read opens the UI");
let ctx = crate::test_helpers::create_test_context_with_config(|c| {
c.auth.enabled = true;
c.auth.public_web_ui = true;
});
let resp = send(&ctx.app, Method::GET, "/api/ui/stats", "").await;
assert_eq!(resp.status(), StatusCode::OK, "public_web_ui opens the UI");
let ctx = crate::test_helpers::create_test_context_with_config(|c| {
c.auth.enabled = true;
c.auth.public_metrics = false;
});
let resp = send(&ctx.app, Method::GET, "/metrics", "").await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"public_metrics=false gates metrics"
);
}
#[tokio::test]
async fn test_token_pages_stay_gated() {
let ctx = crate::test_helpers::create_test_context_with_config(|c| {
c.auth.enabled = true;
c.auth.public_web_ui = true;
});
let resp = send(&ctx.app, Method::GET, "/api/ui/tokens", "").await;
assert_ne!(resp.status(), StatusCode::OK);
}
#[test]
fn test_try_basic_auth_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("htpasswd");
let hash = bcrypt::hash("secret", 4).unwrap();
std::fs::write(&path, format!("admin:{hash}\n")).unwrap();
let htpasswd = super::HtpasswdAuth::from_file(&path).unwrap();
let encoded = STANDARD.encode("admin:secret");
assert_eq!(
super::try_basic_auth(&encoded, Some(&htpasswd)),
Some("admin".to_string())
);
}
#[test]
fn test_try_basic_auth_wrong_password() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("htpasswd");
let hash = bcrypt::hash("secret", 4).unwrap();
std::fs::write(&path, format!("admin:{hash}\n")).unwrap();
let htpasswd = super::HtpasswdAuth::from_file(&path).unwrap();
let encoded = STANDARD.encode("admin:wrong");
assert_eq!(super::try_basic_auth(&encoded, Some(&htpasswd)), None);
}
#[test]
fn test_try_basic_auth_no_store() {
let encoded = STANDARD.encode("admin:secret");
assert_eq!(super::try_basic_auth(&encoded, None), None);
}
#[test]
fn test_try_basic_auth_invalid_base64() {
assert_eq!(super::try_basic_auth("%%%not-base64%%%", None), None);
}
#[tokio::test]
async fn test_opportunistic_basic_auth_on_open_surface() {
let ctx = create_test_context_with_anonymous_read_and_config(&[("admin", "secret")], |c| {
c.npm.proxy = Some("https://registry.npmjs.org".into());
});
let resp = send(&ctx.app, Method::GET, "/api/ui/dashboard", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
for mp in json["mount_points"].as_array().unwrap() {
assert!(
mp["proxy_upstreams"].as_array().unwrap().is_empty(),
"anonymous must see empty proxy_upstreams"
);
}
let header_val = format!("Basic {}", STANDARD.encode("admin:secret"));
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/api/ui/dashboard",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let npm = json["mount_points"]
.as_array()
.unwrap()
.iter()
.find(|m| m["registry"].as_str() == Some("npm"))
.expect("npm mount");
assert!(
!npm["proxy_upstreams"].as_array().unwrap().is_empty(),
"authenticated must see populated proxy_upstreams for npm"
);
}
#[tokio::test]
async fn test_opportunistic_bad_creds_fall_through_to_anonymous() {
let ctx = create_test_context_with_anonymous_read(&[("admin", "secret")]);
let bad = format!("Basic {}", STANDARD.encode("admin:wrong"));
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/api/ui/dashboard",
vec![("authorization", &bad)],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_opportunistic_bearer_auth_on_open_surface() {
let ctx = create_test_context_with_anonymous_read_and_config(&[("admin", "secret")], |c| {
c.npm.proxy = Some("https://registry.npmjs.org".into());
});
let token = ctx
.state
.tokens
.as_ref()
.unwrap()
.create_token("admin", 30, None, crate::tokens::Role::Write)
.unwrap();
let header_val = format!("Bearer {token}");
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/api/ui/dashboard",
vec![("authorization", &header_val)],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let npm = json["mount_points"]
.as_array()
.unwrap()
.iter()
.find(|m| m["registry"].as_str() == Some("npm"))
.expect("npm mount");
assert!(
!npm["proxy_upstreams"].as_array().unwrap().is_empty(),
"bearer-authenticated must see populated proxy_upstreams"
);
}
}