mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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:
parent
518b6ab9ac
commit
e0b8da08f2
8 changed files with 1085 additions and 104 deletions
107
Cargo.lock
generated
107
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ pub mod jwt_auth;
|
|||
pub mod serve;
|
||||
pub mod server;
|
||||
pub mod server_config;
|
||||
pub mod tls;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
72
crates/arc-api/src/tls.rs
Normal 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()))
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue