Add mTLS authentication to arc-api

Support mutual TLS as an authentication strategy alongside JWT.
The server accepts both auth methods on the same port — mTLS if a
client cert is presented, JWT via Bearer header otherwise.

Config changes:
- Replace `authentication_strategy` (singular) with
  `authentication_strategies` (list of "jwt" and/or "mtls")
- Add `[api.tls]` section for cert, key, and CA paths

New files: tls.rs (rustls ServerConfig builder)
Modified: server_config.rs, jwt_auth.rs, serve.rs, lib.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 17:36:22 -05:00
parent 518b6ab9ac
commit e0b8da08f2
8 changed files with 1085 additions and 104 deletions

107
Cargo.lock generated
View file

@ -145,20 +145,29 @@ dependencies = [
"clap",
"dirs",
"http-body-util",
"hyper",
"hyper-util",
"jsonwebtoken",
"openapiv3",
"reqwest 0.12.28",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_yaml",
"sqlx",
"tempfile",
"tokio",
"tokio-rustls",
"tokio-stream",
"toml",
"tower",
"tower-service",
"tracing",
"ulid",
"uuid",
"x509-parser",
]
[[package]]
@ -329,6 +338,45 @@ dependencies = [
"uuid",
]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@ -980,6 +1028,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -2454,6 +2516,15 @@ dependencies = [
"libm",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@ -3240,6 +3311,15 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -3260,6 +3340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@ -3280,6 +3361,15 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@ -5459,6 +5549,23 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"

View file

@ -23,6 +23,14 @@ tokio-stream = { workspace = true, features = ["sync"] }
base64.workspace = true
jsonwebtoken.workspace = true
tokio.workspace = true
tokio-rustls = "0.26"
rustls = { version = "0.23", default-features = false, features = ["std", "ring"] }
rustls-pemfile = "2"
rustls-pki-types = "1"
hyper = "1"
hyper-util = { version = "0.1", features = ["tokio", "server-auto", "http1", "http2"] }
tower-service = "0.3"
x509-parser = "0.16"
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
@ -39,3 +47,5 @@ http-body-util = "0.1"
tempfile = "3"
openapiv3 = "2"
serde_yaml = "0.9"
reqwest = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["std", "ring"] }

View file

@ -4,6 +4,7 @@ use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use rustls_pki_types::CertificateDer;
use serde::Deserialize;
use tracing::warn;
@ -19,18 +20,29 @@ struct Claims {
sub: Option<String>,
}
/// Authentication mode resolved at startup.
/// A single authentication strategy resolved at startup.
#[derive(Clone)]
pub enum AuthMode {
/// JWT verification is enabled with the given decoding key and allowed users.
pub enum AuthStrategy {
Jwt {
key: Arc<DecodingKey>,
allowed_usernames: Vec<String>,
},
/// Authentication is explicitly disabled (insecure, for development only).
Mtls,
}
/// Authentication mode resolved at startup.
#[derive(Clone)]
pub enum AuthMode {
/// One or more strategies to try in order.
Strategies(Vec<AuthStrategy>),
/// Authentication is explicitly disabled (--demo flag only).
Disabled,
}
/// Peer certificates extracted from the TLS connection, inserted as a request extension.
#[derive(Clone)]
pub struct PeerCertificates(pub Option<Vec<CertificateDer<'static>>>);
/// Decode a PEM env var that may be raw PEM or base64-encoded PEM.
fn decode_pem_env(name: &str, value: &str) -> String {
if value.starts_with("-----") {
@ -44,41 +56,122 @@ fn decode_pem_env(name: &str, value: &str) -> String {
/// Resolve the authentication mode from the API config section.
///
/// Call this once at startup before serving requests. Panics if the
/// configuration is invalid (JWT strategy but no public key).
/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config).
pub fn resolve_auth_mode(
api_config: &crate::server_config::ApiConfig,
allowed_usernames: Vec<String>,
) -> AuthMode {
use crate::server_config::ApiAuthenticationStrategy;
use crate::server_config::ApiAuthStrategy;
match api_config.authentication_strategy {
ApiAuthenticationStrategy::InsecureDisabled => {
warn!("JWT authentication disabled");
AuthMode::Disabled
}
ApiAuthenticationStrategy::Jwt => {
let raw = std::env::var("ARC_JWT_PUBLIC_KEY").unwrap_or_else(|_| {
panic!(
"ARC_JWT_PUBLIC_KEY is not set. Either provide an Ed25519 public key in PEM \
format (or base64-encoded PEM) or set authentication_strategy = \
\"insecure_disabled\" in ~/.arc/arc.toml to allow unauthenticated access \
(development only)."
)
});
let pem = decode_pem_env("ARC_JWT_PUBLIC_KEY", &raw);
let key = DecodingKey::from_ed_pem(pem.as_bytes())
.expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key");
AuthMode::Jwt {
key: Arc::new(key),
allowed_usernames,
}
}
if api_config.authentication_strategies.is_empty() {
warn!("No authentication strategies configured; all requests will be rejected");
}
let strategies = api_config
.authentication_strategies
.iter()
.map(|s| match s {
ApiAuthStrategy::Jwt => {
let raw = std::env::var("ARC_JWT_PUBLIC_KEY").unwrap_or_else(|_| {
panic!(
"ARC_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM \
format (or base64-encoded PEM) for JWT authentication."
)
});
let pem = decode_pem_env("ARC_JWT_PUBLIC_KEY", &raw);
let key = DecodingKey::from_ed_pem(pem.as_bytes())
.expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key");
AuthStrategy::Jwt {
key: Arc::new(key),
allowed_usernames: allowed_usernames.clone(),
}
}
ApiAuthStrategy::Mtls => {
assert!(
api_config.tls.is_some(),
"mTLS authentication strategy requires [api.tls] configuration with cert, key, and ca"
);
AuthStrategy::Mtls
}
})
.collect();
AuthMode::Strategies(strategies)
}
/// Axum extractor that enforces JWT authentication on a route.
/// Try to authenticate via JWT. Returns the subject (username) on success.
fn try_jwt(
parts: &Parts,
key: &DecodingKey,
allowed_usernames: &[String],
) -> Result<String, StatusCode> {
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
validation.set_issuer(&["arc-web"]);
let token_data = jsonwebtoken::decode::<Claims>(token, key, &validation)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Fail closed: if no usernames are allowed, reject all requests
if allowed_usernames.is_empty() {
return Err(StatusCode::FORBIDDEN);
}
// Extract GitHub username from sub claim URL (last path segment)
let username = token_data
.claims
.sub
.as_deref()
.and_then(|s| s.rsplit('/').next())
.ok_or(StatusCode::FORBIDDEN)?;
if !allowed_usernames.iter().any(|u| u == username) {
return Err(StatusCode::FORBIDDEN);
}
Ok(username.to_string())
}
/// Try to authenticate via mTLS peer certificates. Returns the CN on success.
fn try_mtls(parts: &Parts) -> Result<String, StatusCode> {
let peer_certs = parts
.extensions
.get::<PeerCertificates>()
.and_then(|pc| pc.0.as_ref())
.ok_or(StatusCode::UNAUTHORIZED)?;
if peer_certs.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
// Extract CN from the first (leaf) certificate
let cert = &peer_certs[0];
let (_, parsed) = x509_parser::parse_x509_certificate(cert)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let cn = parsed
.subject()
.iter_common_name()
.next()
.and_then(|cn| cn.as_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(cn.to_string())
}
/// Axum extractor that enforces authentication on a route.
///
/// Add this as a parameter to any handler to require a valid JWT.
/// Tries each configured strategy in order. The first successful match wins.
/// The `AuthMode` must be added to the router as an Extension.
/// When auth is disabled, the extractor accepts all requests.
pub struct AuthenticatedService;
@ -92,49 +185,34 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
.get::<AuthMode>()
.expect("AuthMode extension must be added to the router");
let (key, allowed_usernames) = match auth_mode {
let strategies = match auth_mode {
AuthMode::Disabled => return Ok(AuthenticatedService),
AuthMode::Jwt {
key,
allowed_usernames,
} => (key, allowed_usernames),
AuthMode::Strategies(strategies) => strategies,
};
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
validation.set_issuer(&["arc-web"]);
let token_data = jsonwebtoken::decode::<Claims>(token, key, &validation)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Fail closed: if no usernames are allowed, reject all requests
if allowed_usernames.is_empty() {
return Err(StatusCode::FORBIDDEN);
if strategies.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
// Extract GitHub username from sub claim URL (last path segment)
let username = token_data
.claims
.sub
.as_deref()
.and_then(|s| s.rsplit('/').next())
.ok_or(StatusCode::FORBIDDEN)?;
let mut last_err = StatusCode::UNAUTHORIZED;
if !allowed_usernames.iter().any(|u| u == username) {
return Err(StatusCode::FORBIDDEN);
for strategy in strategies {
match strategy {
AuthStrategy::Mtls => match try_mtls(parts) {
Ok(_subject) => return Ok(AuthenticatedService),
Err(e) => last_err = e,
},
AuthStrategy::Jwt {
key,
allowed_usernames,
} => match try_jwt(parts, key, allowed_usernames) {
Ok(_subject) => return Ok(AuthenticatedService),
Err(e) => last_err = e,
},
}
}
Ok(AuthenticatedService)
Err(last_err)
}
}
@ -207,12 +285,109 @@ mod tests {
}
fn jwt_mode(decoding: DecodingKey, allowed_usernames: Vec<&str>) -> AuthMode {
AuthMode::Jwt {
AuthMode::Strategies(vec![AuthStrategy::Jwt {
key: Arc::new(decoding),
allowed_usernames: allowed_usernames.into_iter().map(String::from).collect(),
}
}])
}
/// Build a test request with PeerCertificates extension pre-inserted.
fn request_with_peer_certs(
uri: &str,
certs: Option<Vec<CertificateDer<'static>>>,
) -> Request<Body> {
let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap();
req.extensions_mut().insert(PeerCertificates(certs));
req
}
/// Generate a self-signed CA + client cert for mTLS testing.
/// Returns (ca_cert_der, client_cert_der) where client_cert_der has the given CN.
fn generate_test_client_cert(cn: &str) -> CertificateDer<'static> {
use std::process::{Command, Stdio};
// Generate CA key + self-signed cert
let ca_key = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.expect("openssl genpkey failed")
.stdout;
let ca_cert = {
let mut child = Command::new("openssl")
.args([
"req", "-new", "-x509", "-key", "/dev/stdin", "-days", "1",
"-subj", "/CN=TestCA",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("openssl req failed");
std::io::Write::write_all(&mut child.stdin.take().unwrap(), &ca_key).unwrap();
child.wait_with_output().unwrap().stdout
};
// Generate client key
let client_key = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.expect("openssl genpkey failed")
.stdout;
// Generate client CSR
let subj = format!("/CN={cn}");
let client_csr = {
let mut child = Command::new("openssl")
.args([
"req", "-new", "-key", "/dev/stdin", "-subj", &subj,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("openssl req failed");
std::io::Write::write_all(&mut child.stdin.take().unwrap(), &client_key).unwrap();
child.wait_with_output().unwrap().stdout
};
// Sign client cert with CA
let dir = tempfile::tempdir().unwrap();
let ca_cert_path = dir.path().join("ca.crt");
let ca_key_path = dir.path().join("ca.key");
let csr_path = dir.path().join("client.csr");
std::fs::write(&ca_cert_path, &ca_cert).unwrap();
std::fs::write(&ca_key_path, &ca_key).unwrap();
std::fs::write(&csr_path, &client_csr).unwrap();
let client_cert_pem = Command::new("openssl")
.args([
"x509", "-req",
"-in", csr_path.to_str().unwrap(),
"-CA", ca_cert_path.to_str().unwrap(),
"-CAkey", ca_key_path.to_str().unwrap(),
"-CAcreateserial",
"-days", "1",
])
.output()
.expect("openssl x509 failed")
.stdout;
// Convert PEM to DER
let client_cert_der = {
let mut child = Command::new("openssl")
.args(["x509", "-outform", "DER"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("openssl x509 DER conversion failed");
std::io::Write::write_all(&mut child.stdin.take().unwrap(), &client_cert_pem).unwrap();
child.wait_with_output().unwrap().stdout
};
CertificateDer::from(client_cert_der)
}
// --- JWT tests (updated for Strategies wrapper) ---
#[tokio::test]
async fn rejects_missing_auth_header() {
let (_, decoding) = generate_test_keypair();
@ -372,20 +547,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn resolve_auth_mode_insecure_disabled() {
use crate::server_config::{ApiAuthenticationStrategy, ApiConfig};
let config = ApiConfig {
authentication_strategy: ApiAuthenticationStrategy::InsecureDisabled,
..ApiConfig::default()
};
assert!(matches!(
resolve_auth_mode(&config, vec![]),
AuthMode::Disabled
));
}
#[tokio::test]
async fn disabled_mode_allows_all_requests() {
let app = test_router(AuthMode::Disabled);
@ -395,4 +556,110 @@ mod tests {
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn empty_strategies_rejects() {
let app = test_router(AuthMode::Strategies(vec![]));
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// --- mTLS tests ---
#[tokio::test]
async fn mtls_accepts_valid_peer_cert() {
let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls]));
let cert = generate_test_client_cert("testuser");
let req = request_with_peer_certs("/test", Some(vec![cert]));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn mtls_rejects_no_peer_certs() {
let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls]));
let req = request_with_peer_certs("/test", None);
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn mtls_rejects_empty_peer_certs() {
let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls]));
let req = request_with_peer_certs("/test", Some(vec![]));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn mtls_rejects_when_no_peer_certs_extension() {
let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls]));
// No PeerCertificates extension at all (plain HTTP path)
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// --- Multi-strategy tests ---
#[tokio::test]
async fn jwt_and_mtls_accepts_valid_cert_no_jwt() {
let (_, decoding) = generate_test_keypair();
let mode = AuthMode::Strategies(vec![
AuthStrategy::Jwt {
key: Arc::new(decoding),
allowed_usernames: vec!["brynary".to_string()],
},
AuthStrategy::Mtls,
]);
let app = test_router(mode);
let cert = generate_test_client_cert("brynary");
let req = request_with_peer_certs("/test", Some(vec![cert]));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn mtls_and_jwt_falls_back_to_jwt() {
let (encoding, decoding) = generate_test_keypair();
let mode = AuthMode::Strategies(vec![
AuthStrategy::Mtls,
AuthStrategy::Jwt {
key: Arc::new(decoding),
allowed_usernames: vec!["brynary".to_string()],
},
]);
let app = test_router(mode);
let token = sign_token(
&encoding,
"arc-web",
60,
Some("https://github.com/brynary"),
);
// No peer certs, but valid JWT
let mut req = Request::builder()
.uri("/test")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(PeerCertificates(None));
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}

View file

@ -3,3 +3,4 @@ pub mod jwt_auth;
pub mod serve;
pub mod server;
pub mod server_config;
pub mod tls;

View file

@ -3,11 +3,13 @@ use std::sync::Arc;
use arc_llm::provider::Provider;
use arc_util::terminal::Styles;
use tokio::net::TcpListener;
use tracing::info;
use tracing::{error, info};
use clap::Args;
use crate::jwt_auth::PeerCertificates;
use crate::server::{build_router, create_app_state_with_options};
use crate::server_config::ApiAuthStrategy;
use arc_workflows::cli::backend::AgentApiBackend;
use arc_workflows::cli::SandboxProvider;
use arc_workflows::handler::default_registry;
@ -147,7 +149,77 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
eprintln!("{}", styles.dim.apply_to("(dry-run mode)"));
}
axum::serve(listener, router).await?;
// Branch: TLS or plain HTTP
if let Some(ref tls_config) = server_config.api.tls {
let mtls_enabled = server_config
.api
.authentication_strategies
.contains(&ApiAuthStrategy::Mtls);
let mtls_optional = mtls_enabled && server_config.api.authentication_strategies.len() > 1;
let rustls_config =
crate::tls::build_rustls_config(tls_config, mtls_enabled, mtls_optional);
let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config);
info!("TLS enabled (mTLS {})", if mtls_enabled { "on" } else { "off" });
serve_tls(listener, tls_acceptor, router).await?;
} else {
axum::serve(listener, router).await?;
}
Ok(())
}
/// Serve requests over TLS, extracting peer certificates into request extensions.
async fn serve_tls(
listener: TcpListener,
tls_acceptor: tokio_rustls::TlsAcceptor,
router: axum::Router,
) -> anyhow::Result<()> {
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use tower_service::Service;
let builder = Builder::new(TokioExecutor::new());
loop {
let (tcp_stream, remote_addr) = listener.accept().await?;
let tls_acceptor = tls_acceptor.clone();
let router = router.clone();
let builder = builder.clone();
tokio::spawn(async move {
let tls_stream = match tls_acceptor.accept(tcp_stream).await {
Ok(s) => s,
Err(e) => {
error!(%remote_addr, "TLS handshake failed: {e}");
return;
}
};
// Extract peer certificates from the TLS connection
let peer_certs = tls_stream
.get_ref()
.1
.peer_certificates()
.map(|certs| certs.to_vec());
let io = TokioIo::new(tls_stream);
let service = hyper::service::service_fn(move |mut req: hyper::Request<hyper::body::Incoming>| {
// Insert peer certificates into request extensions
req.extensions_mut()
.insert(PeerCertificates(peer_certs.clone()));
let mut router = router.clone();
async move { router.call(req).await }
});
if let Err(e) = builder.serve_connection(io, service).await {
error!(%remote_addr, "connection error: {e}");
}
});
}
}

View file

@ -19,12 +19,18 @@ pub struct AuthConfig {
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ApiAuthenticationStrategy {
#[default]
pub enum ApiAuthStrategy {
Jwt,
InsecureDisabled,
Mtls,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TlsConfig {
pub cert: PathBuf,
pub key: PathBuf,
pub ca: PathBuf,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
@ -32,7 +38,8 @@ pub struct ApiConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub authentication_strategy: ApiAuthenticationStrategy,
pub authentication_strategies: Vec<ApiAuthStrategy>,
pub tls: Option<TlsConfig>,
}
fn default_base_url() -> String {
@ -43,7 +50,8 @@ impl Default for ApiConfig {
fn default() -> Self {
Self {
base_url: default_base_url(),
authentication_strategy: ApiAuthenticationStrategy::default(),
authentication_strategies: Vec::new(),
tls: None,
}
}
}
@ -171,7 +179,7 @@ allowed_usernames = ["brynary", "alice"]
[api]
base_url = "http://example.com:8080"
authentication_strategy = "jwt"
authentication_strategies = ["jwt"]
[git]
provider = "github"
@ -183,10 +191,7 @@ client_id = "Iv1.abc123"
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
assert_eq!(config.api.base_url, "http://example.com:8080");
assert_eq!(
config.api.authentication_strategy,
ApiAuthenticationStrategy::Jwt
);
assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]);
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
@ -206,10 +211,8 @@ client_id = "Iv1.abc123"
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.api.base_url, "http://localhost:3000");
assert_eq!(
config.api.authentication_strategy,
ApiAuthenticationStrategy::Jwt
);
assert!(config.api.authentication_strategies.is_empty());
assert!(config.api.tls.is_none());
}
#[test]
@ -279,19 +282,55 @@ model = "gpt-4"
}
#[test]
fn parse_insecure_disabled_values() {
fn parse_insecure_disabled_auth_provider() {
let toml = r#"
[web.auth]
provider = "insecure_disabled"
[api]
authentication_strategy = "insecure_disabled"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled);
}
#[test]
fn parse_jwt_and_mtls_strategies() {
let toml = r#"
[api]
authentication_strategies = ["jwt", "mtls"]
[api.tls]
cert = "~/.arc/certs/server.crt"
key = "~/.arc/certs/server.key"
ca = "~/.arc/certs/ca.crt"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.api.authentication_strategy,
ApiAuthenticationStrategy::InsecureDisabled
config.api.authentication_strategies,
vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]
);
let tls = config.api.tls.unwrap();
assert_eq!(tls.cert, PathBuf::from("~/.arc/certs/server.crt"));
assert_eq!(tls.key, PathBuf::from("~/.arc/certs/server.key"));
assert_eq!(tls.ca, PathBuf::from("~/.arc/certs/ca.crt"));
}
#[test]
fn parse_empty_strategies() {
let toml = r#"
[api]
authentication_strategies = []
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert!(config.api.authentication_strategies.is_empty());
}
#[test]
fn parse_jwt_only_strategy() {
let toml = r#"
[api]
authentication_strategies = ["jwt"]
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]);
assert!(config.api.tls.is_none());
}
}

72
crates/arc-api/src/tls.rs Normal file
View file

@ -0,0 +1,72 @@
use std::path::Path;
use std::sync::Arc;
use rustls::server::WebPkiClientVerifier;
use rustls::ServerConfig;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use crate::server_config::TlsConfig;
/// Build a rustls `ServerConfig` from the `[api.tls]` configuration.
///
/// - `mtls_enabled`: whether mTLS is listed as an authentication strategy.
/// - `mtls_optional`: whether other strategies (e.g. JWT) are also present,
/// meaning client certs should be requested but not required.
pub fn build_rustls_config(
tls_config: &TlsConfig,
mtls_enabled: bool,
mtls_optional: bool,
) -> Arc<ServerConfig> {
let certs = load_certs(&tls_config.cert);
let key = load_private_key(&tls_config.key);
let config = if mtls_enabled {
let ca_certs = load_certs(&tls_config.ca);
let mut root_store = rustls::RootCertStore::empty();
for cert in ca_certs {
root_store.add(cert).expect("failed to add CA certificate to root store");
}
let verifier = if mtls_optional {
WebPkiClientVerifier::builder(Arc::new(root_store))
.allow_unauthenticated()
.build()
.expect("failed to build optional client verifier")
} else {
WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.expect("failed to build required client verifier")
};
ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.expect("invalid server certificate or key")
} else {
// TLS for encryption only, no client cert verification
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.expect("invalid server certificate or key")
};
Arc::new(config)
}
fn load_certs(path: &Path) -> Vec<CertificateDer<'static>> {
let file = std::fs::File::open(path)
.unwrap_or_else(|e| panic!("failed to open certificate file {}: {e}", path.display()));
let mut reader = std::io::BufReader::new(file);
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.unwrap_or_else(|e| panic!("failed to parse certificates from {}: {e}", path.display()))
}
fn load_private_key(path: &Path) -> PrivateKeyDer<'static> {
let file = std::fs::File::open(path)
.unwrap_or_else(|e| panic!("failed to open private key file {}: {e}", path.display()));
let mut reader = std::io::BufReader::new(file);
rustls_pemfile::private_key(&mut reader)
.unwrap_or_else(|e| panic!("failed to parse private key from {}: {e}", path.display()))
.unwrap_or_else(|| panic!("no private key found in {}", path.display()))
}

View file

@ -1,3 +1,416 @@
// ===========================================================================
// mTLS end-to-end tests
// ===========================================================================
mod mtls_e2e {
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use arc_api::jwt_auth::{AuthMode, AuthStrategy, PeerCertificates};
use arc_api::server::{build_router, create_app_state};
use arc_api::server_config::TlsConfig;
use arc_api::tls::build_rustls_config;
use arc_workflows::handler::codergen::CodergenHandler;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::interviewer::Interviewer;
use rustls;
use tokio::net::TcpListener;
fn simple_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("codergen", Box::new(CodergenHandler::new(None)));
registry
}
async fn test_db() -> sqlx::SqlitePool {
let pool = arc_db::connect_memory().await.unwrap();
arc_db::initialize_db(&pool).await.unwrap();
pool
}
/// Generate a complete CA + server cert + client cert PKI in `dir`.
/// Returns paths: (ca_cert, server_cert, server_key, client_cert_pem, client_key_pem)
fn generate_pki(dir: &Path, ca_cn: &str, server_cn: &str, client_cn: &str) -> PkiPaths {
// CA key
let ca_key_path = dir.join("ca.key");
let ca_cert_path = dir.join("ca.crt");
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", ca_key_path.to_str().unwrap()]);
run_openssl(&[
"req", "-new", "-x509",
"-key", ca_key_path.to_str().unwrap(),
"-out", ca_cert_path.to_str().unwrap(),
"-days", "1",
"-subj", &format!("/CN={ca_cn}"),
]);
// Server key + cert signed by CA
let server_key_path = dir.join("server.key");
let server_csr_path = dir.join("server.csr");
let server_cert_path = dir.join("server.crt");
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", server_key_path.to_str().unwrap()]);
run_openssl(&[
"req", "-new",
"-key", server_key_path.to_str().unwrap(),
"-out", server_csr_path.to_str().unwrap(),
"-subj", &format!("/CN={server_cn}"),
]);
// Create extension file for SAN (reqwest validates server cert hostname)
let ext_path = dir.join("server.ext");
std::fs::write(&ext_path, "subjectAltName=IP:127.0.0.1").unwrap();
run_openssl(&[
"x509", "-req",
"-in", server_csr_path.to_str().unwrap(),
"-CA", ca_cert_path.to_str().unwrap(),
"-CAkey", ca_key_path.to_str().unwrap(),
"-CAcreateserial",
"-out", server_cert_path.to_str().unwrap(),
"-days", "1",
"-extfile", ext_path.to_str().unwrap(),
]);
// Client key + cert signed by CA
let client_key_path = dir.join("client.key");
let client_csr_path = dir.join("client.csr");
let client_cert_path = dir.join("client.crt");
run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", client_key_path.to_str().unwrap()]);
run_openssl(&[
"req", "-new",
"-key", client_key_path.to_str().unwrap(),
"-out", client_csr_path.to_str().unwrap(),
"-subj", &format!("/CN={client_cn}"),
]);
run_openssl(&[
"x509", "-req",
"-in", client_csr_path.to_str().unwrap(),
"-CA", ca_cert_path.to_str().unwrap(),
"-CAkey", ca_key_path.to_str().unwrap(),
"-CAcreateserial",
"-out", client_cert_path.to_str().unwrap(),
"-days", "1",
]);
PkiPaths {
ca_cert: ca_cert_path,
server_cert: server_cert_path,
server_key: server_key_path,
client_cert: client_cert_path,
client_key: client_key_path,
}
}
struct PkiPaths {
ca_cert: std::path::PathBuf,
server_cert: std::path::PathBuf,
server_key: std::path::PathBuf,
client_cert: std::path::PathBuf,
client_key: std::path::PathBuf,
}
fn run_openssl(args: &[&str]) {
let output = Command::new("openssl")
.args(args)
.stdin(Stdio::null())
.output()
.expect("openssl command failed to execute");
assert!(
output.status.success(),
"openssl {} failed: {}",
args[0],
String::from_utf8_lossy(&output.stderr)
);
}
/// Start a TLS server on a random port, returning the bound address.
/// `mtls_optional`: if true, client certs are requested but not required (for multi-strategy).
/// `auth_mode`: the authentication mode to use for the router.
async fn start_tls_server(
tls_config: &TlsConfig,
mtls_optional: bool,
auth_mode: AuthMode,
) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let rustls_config = build_rustls_config(tls_config, true, mtls_optional);
let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config);
let state = create_app_state(test_db().await, simple_registry);
let router = build_router(state, auth_mode);
tokio::spawn(async move {
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use tower_service::Service;
let builder = Builder::new(TokioExecutor::new());
loop {
let (tcp_stream, _remote_addr) = match listener.accept().await {
Ok(s) => s,
Err(_) => return,
};
let tls_acceptor = tls_acceptor.clone();
let router = router.clone();
let builder = builder.clone();
tokio::spawn(async move {
let tls_stream = match tls_acceptor.accept(tcp_stream).await {
Ok(s) => s,
Err(_) => return,
};
let peer_certs = tls_stream
.get_ref()
.1
.peer_certificates()
.map(|certs| certs.to_vec());
let io = TokioIo::new(tls_stream);
let service = hyper::service::service_fn(
move |mut req: hyper::Request<hyper::body::Incoming>| {
req.extensions_mut()
.insert(PeerCertificates(peer_certs.clone()));
let mut router = router.clone();
async move { router.call(req).await }
},
);
let _ = builder.serve_connection(io, service).await;
});
}
});
addr
}
/// Build a reqwest client with the given CA cert and optional client identity.
fn build_client(
ca_cert_path: &Path,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> reqwest::Client {
let ca_pem = std::fs::read(ca_cert_path).unwrap();
let ca_cert = reqwest::tls::Certificate::from_pem(&ca_pem).unwrap();
let mut builder = reqwest::Client::builder()
.add_root_certificate(ca_cert)
.use_rustls_tls();
if let (Some(cert_path), Some(key_path)) = (client_cert_path, client_key_path) {
let cert_pem = std::fs::read(cert_path).unwrap();
let key_pem = std::fs::read(key_path).unwrap();
let mut identity_pem = cert_pem;
identity_pem.extend_from_slice(&key_pem);
let identity = reqwest::tls::Identity::from_pem(&identity_pem).unwrap();
builder = builder.identity(identity);
}
builder.build().unwrap()
}
fn install_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[tokio::test]
async fn mtls_accepts_valid_client_cert() {
install_crypto_provider();
let dir = tempfile::tempdir().unwrap();
let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser");
let tls_config = TlsConfig {
cert: pki.server_cert.clone(),
key: pki.server_key.clone(),
ca: pki.ca_cert.clone(),
};
let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]);
let addr = start_tls_server(&tls_config, false, auth_mode).await;
let client = build_client(
&pki.ca_cert,
Some(&pki.client_cert),
Some(&pki.client_key),
);
let response = client
.get(format!("https://127.0.0.1:{}/runs", addr.port()))
.send()
.await
.expect("request with valid client cert should succeed");
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn mtls_only_rejects_wrong_ca_client_cert() {
install_crypto_provider();
let dir = tempfile::tempdir().unwrap();
let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser");
let tls_config = TlsConfig {
cert: pki.server_cert.clone(),
key: pki.server_key.clone(),
ca: pki.ca_cert.clone(),
};
let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]);
let addr = start_tls_server(&tls_config, false, auth_mode).await;
// Generate a DIFFERENT CA and client cert signed by it
let wrong_dir = dir.path().join("wrong_ca");
std::fs::create_dir_all(&wrong_dir).unwrap();
let wrong_pki = generate_pki(&wrong_dir, "WrongCA", "localhost", "intruder");
// Client trusts the REAL server CA, but presents a cert from the WRONG CA
let client = build_client(
&pki.ca_cert,
Some(&wrong_pki.client_cert),
Some(&wrong_pki.client_key),
);
let result = client
.get(format!("https://127.0.0.1:{}/runs", addr.port()))
.send()
.await;
// Server should reject the TLS handshake — the wrong CA client cert
// will cause a connection error (not an HTTP error)
assert!(
result.is_err(),
"request with wrong-CA client cert should fail at TLS level, but got: {:?}",
result.unwrap().status()
);
}
#[tokio::test]
async fn mtls_only_rejects_no_client_cert() {
install_crypto_provider();
let dir = tempfile::tempdir().unwrap();
let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser");
let tls_config = TlsConfig {
cert: pki.server_cert.clone(),
key: pki.server_key.clone(),
ca: pki.ca_cert.clone(),
};
// mTLS is the ONLY strategy → client cert is required at TLS level
let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]);
let addr = start_tls_server(&tls_config, false, auth_mode).await;
// Client trusts the server CA but presents NO client cert
let client = build_client(&pki.ca_cert, None, None);
let result = client
.get(format!("https://127.0.0.1:{}/runs", addr.port()))
.send()
.await;
// Server requires client cert → TLS handshake should fail
assert!(
result.is_err(),
"request without client cert should fail when mTLS is the only strategy, but got: {:?}",
result.unwrap().status()
);
}
/// Generate an Ed25519 JWT keypair. Returns (encoding_key, decoding_key).
fn generate_jwt_keypair() -> (jsonwebtoken::EncodingKey, jsonwebtoken::DecodingKey) {
let output = Command::new("openssl")
.args(["genpkey", "-algorithm", "Ed25519"])
.output()
.expect("openssl must be available for tests");
let private_pem = output.stdout;
let output = Command::new("openssl")
.args(["pkey", "-pubout"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(&private_pem).unwrap();
child.wait_with_output()
})
.expect("openssl pkey failed");
let public_pem = output.stdout;
let encoding =
jsonwebtoken::EncodingKey::from_ed_pem(&private_pem).expect("invalid private key");
let decoding =
jsonwebtoken::DecodingKey::from_ed_pem(&public_pem).expect("invalid public key");
(encoding, decoding)
}
fn sign_jwt(key: &jsonwebtoken::EncodingKey, sub: &str) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": "arc-web",
"iat": now,
"exp": now + 60,
"sub": sub,
});
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
jsonwebtoken::encode(&header, &claims, key).expect("failed to sign token")
}
#[tokio::test]
async fn mtls_and_jwt_accepts_valid_jwt_without_client_cert() {
install_crypto_provider();
let dir = tempfile::tempdir().unwrap();
let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser");
let tls_config = TlsConfig {
cert: pki.server_cert.clone(),
key: pki.server_key.clone(),
ca: pki.ca_cert.clone(),
};
let (encoding_key, decoding_key) = generate_jwt_keypair();
// Both mTLS and JWT strategies; mTLS is optional since JWT is also present
let auth_mode = AuthMode::Strategies(vec![
AuthStrategy::Mtls,
AuthStrategy::Jwt {
key: Arc::new(decoding_key),
allowed_usernames: vec!["brynary".to_string()],
},
]);
let addr = start_tls_server(&tls_config, true, auth_mode).await;
// Client trusts the server CA but presents NO client cert
let client = build_client(&pki.ca_cert, None, None);
let token = sign_jwt(&encoding_key, "https://github.com/brynary");
let response = client
.get(format!("https://127.0.0.1:{}/runs", addr.port()))
.bearer_auth(&token)
.send()
.await
.expect("request with valid JWT and no client cert should succeed");
assert_eq!(
response.status(),
200,
"valid JWT should be accepted when strategies = [mtls, jwt]"
);
}
}
// ===========================================================================
// Full HTTP server lifecycle (TS Scenario 4)
// ===========================================================================