mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
780 lines
25 KiB
Rust
780 lines
25 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::FromRequestParts;
|
|
use axum::http::request::Parts;
|
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
|
|
use rustls_pki_types::CertificateDer;
|
|
use serde::Deserialize;
|
|
use tracing::warn;
|
|
|
|
use crate::error::ApiError;
|
|
|
|
/// JWT claims for service-to-service authentication.
|
|
#[derive(Debug, Deserialize)]
|
|
struct Claims {
|
|
#[allow(dead_code)]
|
|
iss: String,
|
|
#[allow(dead_code)]
|
|
iat: u64,
|
|
#[allow(dead_code)]
|
|
exp: u64,
|
|
sub: Option<String>,
|
|
}
|
|
|
|
/// A single authentication strategy resolved at startup.
|
|
#[derive(Clone)]
|
|
pub enum AuthStrategy {
|
|
Jwt {
|
|
key: Arc<DecodingKey>,
|
|
validation: Arc<Validation>,
|
|
allowed_usernames: Vec<String>,
|
|
},
|
|
Mtls,
|
|
}
|
|
|
|
pub fn jwt_validation() -> Validation {
|
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
|
validation.set_required_spec_claims(&["iss", "iat", "exp"]);
|
|
validation.set_issuer(&["fabro-web"]);
|
|
validation
|
|
}
|
|
|
|
/// 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 (used for demo requests via `X-Fabro-Demo: 1` header).
|
|
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.
|
|
pub fn decode_pem_env(name: &str, value: &str) -> String {
|
|
if value.starts_with("-----") {
|
|
return value.to_string();
|
|
}
|
|
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
|
|
.unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}"));
|
|
String::from_utf8(bytes)
|
|
.unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}"))
|
|
}
|
|
|
|
/// 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, or mTLS without TLS config).
|
|
pub fn resolve_auth_mode(
|
|
api_config: &fabro_config::server::ApiSettings,
|
|
allowed_usernames: Vec<String>,
|
|
) -> AuthMode {
|
|
use fabro_config::server::ApiAuthStrategy;
|
|
|
|
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("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|_| {
|
|
panic!(
|
|
"FABRO_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("FABRO_JWT_PUBLIC_KEY", &raw);
|
|
let key = DecodingKey::from_ed_pem(pem.as_bytes())
|
|
.expect("FABRO_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key");
|
|
AuthStrategy::Jwt {
|
|
key: Arc::new(key),
|
|
validation: Arc::new(jwt_validation()),
|
|
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)
|
|
}
|
|
|
|
/// Extract the login from JWT claims.
|
|
fn extract_jwt_login(parts: &Parts, key: &DecodingKey, validation: &Validation) -> Option<String> {
|
|
let header = parts
|
|
.headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())?;
|
|
let token = header.strip_prefix("Bearer ")?;
|
|
let token_data = jsonwebtoken::decode::<Claims>(token, key, validation).ok()?;
|
|
token_data
|
|
.claims
|
|
.sub
|
|
.as_deref()
|
|
.and_then(|s| s.rsplit('/').next())
|
|
.map(String::from)
|
|
}
|
|
|
|
/// Extract the CN from mTLS peer certificates.
|
|
fn extract_mtls_cn(parts: &Parts) -> Option<String> {
|
|
let peer_certs = parts
|
|
.extensions
|
|
.get::<PeerCertificates>()
|
|
.and_then(|pc| pc.0.as_ref())?;
|
|
let cert = peer_certs.first()?;
|
|
let (_, parsed) = x509_parser::parse_x509_certificate(cert).ok()?;
|
|
let cn = parsed
|
|
.subject()
|
|
.iter_common_name()
|
|
.next()
|
|
.and_then(|cn| cn.as_str().ok())
|
|
.map(String::from);
|
|
cn
|
|
}
|
|
|
|
/// Try to authenticate via JWT.
|
|
fn try_jwt(
|
|
parts: &Parts,
|
|
key: &DecodingKey,
|
|
validation: &Validation,
|
|
allowed_usernames: &[String],
|
|
) -> Result<(), ApiError> {
|
|
let header = parts
|
|
.headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(ApiError::unauthorized)?;
|
|
|
|
let token = header
|
|
.strip_prefix("Bearer ")
|
|
.ok_or_else(ApiError::unauthorized)?;
|
|
|
|
let token_data = jsonwebtoken::decode::<Claims>(token, key, validation)
|
|
.map_err(|_| ApiError::unauthorized())?;
|
|
|
|
// Fail closed: if no usernames are allowed, reject all requests
|
|
if allowed_usernames.is_empty() {
|
|
return Err(ApiError::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_else(ApiError::forbidden)?;
|
|
|
|
if !allowed_usernames.iter().any(|u| u == username) {
|
|
return Err(ApiError::forbidden());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Try to authenticate via mTLS peer certificates.
|
|
fn try_mtls(parts: &Parts) -> Result<(), ApiError> {
|
|
let peer_certs = parts
|
|
.extensions
|
|
.get::<PeerCertificates>()
|
|
.and_then(|pc| pc.0.as_ref())
|
|
.ok_or_else(ApiError::unauthorized)?;
|
|
|
|
if peer_certs.is_empty() {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
|
|
// Verify we can parse the leaf certificate and extract a CN
|
|
let cert = &peer_certs[0];
|
|
let (_, parsed) =
|
|
x509_parser::parse_x509_certificate(cert).map_err(|_| ApiError::unauthorized())?;
|
|
|
|
parsed
|
|
.subject()
|
|
.iter_common_name()
|
|
.next()
|
|
.and_then(|cn| cn.as_str().ok())
|
|
.ok_or_else(ApiError::unauthorized)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Axum extractor that enforces authentication on a route.
|
|
///
|
|
/// 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;
|
|
|
|
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedService {
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
let auth_mode = parts
|
|
.extensions
|
|
.get::<AuthMode>()
|
|
.expect("AuthMode extension must be added to the router");
|
|
|
|
let strategies = match auth_mode {
|
|
AuthMode::Disabled => return Ok(AuthenticatedService),
|
|
AuthMode::Strategies(strategies) => strategies,
|
|
};
|
|
|
|
if strategies.is_empty() {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
|
|
let mut last_err = ApiError::unauthorized();
|
|
|
|
for strategy in strategies {
|
|
let result = match strategy {
|
|
AuthStrategy::Mtls => try_mtls(parts),
|
|
AuthStrategy::Jwt {
|
|
key,
|
|
validation,
|
|
allowed_usernames,
|
|
} => try_jwt(parts, key, validation, allowed_usernames),
|
|
};
|
|
match result {
|
|
Ok(()) => return Ok(AuthenticatedService),
|
|
Err(e) => last_err = e,
|
|
}
|
|
}
|
|
|
|
Err(last_err)
|
|
}
|
|
}
|
|
|
|
/// Axum extractor that authenticates and extracts the user's login.
|
|
///
|
|
/// - Demo mode → `login: "demo"`
|
|
/// - JWT → login from the `sub` claim (last path segment of URL)
|
|
/// - mTLS → CN from the peer certificate
|
|
pub struct AuthenticatedUser {
|
|
pub login: String,
|
|
}
|
|
|
|
impl<S: Send + Sync> FromRequestParts<S> for AuthenticatedUser {
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
let auth_mode = parts
|
|
.extensions
|
|
.get::<AuthMode>()
|
|
.expect("AuthMode extension must be added to the router");
|
|
|
|
let strategies = match auth_mode {
|
|
AuthMode::Disabled => {
|
|
return Ok(AuthenticatedUser {
|
|
login: "demo".to_string(),
|
|
})
|
|
}
|
|
AuthMode::Strategies(strategies) => strategies,
|
|
};
|
|
|
|
if strategies.is_empty() {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
|
|
let mut last_err = ApiError::unauthorized();
|
|
|
|
for strategy in strategies {
|
|
match strategy {
|
|
AuthStrategy::Jwt {
|
|
key,
|
|
validation,
|
|
allowed_usernames,
|
|
} => {
|
|
if try_jwt(parts, key, validation, allowed_usernames).is_ok() {
|
|
if let Some(login) = extract_jwt_login(parts, key, validation) {
|
|
return Ok(AuthenticatedUser { login });
|
|
}
|
|
}
|
|
last_err = ApiError::unauthorized();
|
|
}
|
|
AuthStrategy::Mtls => {
|
|
if try_mtls(parts).is_ok() {
|
|
if let Some(login) = extract_mtls_cn(parts) {
|
|
return Ok(AuthenticatedUser { login });
|
|
}
|
|
}
|
|
last_err = ApiError::unauthorized();
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(last_err)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use tower::ServiceExt;
|
|
|
|
async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse {
|
|
"ok"
|
|
}
|
|
|
|
fn test_router(mode: AuthMode) -> Router {
|
|
Router::new()
|
|
.route("/test", get(protected_handler))
|
|
.layer(axum::Extension(mode))
|
|
}
|
|
|
|
fn generate_test_keypair() -> (jsonwebtoken::EncodingKey, DecodingKey) {
|
|
let output = std::process::Command::new("openssl")
|
|
.args(["genpkey", "-algorithm", "Ed25519"])
|
|
.output()
|
|
.expect("openssl must be available for tests");
|
|
let private_pem = output.stdout;
|
|
|
|
let output = std::process::Command::new("openssl")
|
|
.args(["pkey", "-pubout"])
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::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 = DecodingKey::from_ed_pem(&public_pem).expect("invalid public key");
|
|
(encoding, decoding)
|
|
}
|
|
|
|
fn sign_token(
|
|
key: &jsonwebtoken::EncodingKey,
|
|
iss: &str,
|
|
exp_secs: u64,
|
|
sub: Option<&str>,
|
|
) -> String {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let mut claims = serde_json::json!({
|
|
"iss": iss,
|
|
"iat": now,
|
|
"exp": now + exp_secs,
|
|
});
|
|
if let Some(sub) = sub {
|
|
claims["sub"] = serde_json::Value::String(sub.to_string());
|
|
}
|
|
let header = jsonwebtoken::Header::new(Algorithm::EdDSA);
|
|
jsonwebtoken::encode(&header, &claims, key).expect("failed to sign token")
|
|
}
|
|
|
|
fn jwt_mode(decoding: DecodingKey, allowed_usernames: Vec<&str>) -> AuthMode {
|
|
AuthMode::Strategies(vec![AuthStrategy::Jwt {
|
|
key: Arc::new(decoding),
|
|
validation: Arc::new(jwt_validation()),
|
|
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();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_invalid_token() {
|
|
let (_, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", "Bearer invalid.token.here")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepts_valid_token_with_matching_username() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let token = sign_token(
|
|
&encoding,
|
|
"fabro-web",
|
|
60,
|
|
Some("https://github.com/brynary"),
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_token_with_non_matching_username() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let token = sign_token(
|
|
&encoding,
|
|
"fabro-web",
|
|
60,
|
|
Some("https://github.com/someone-else"),
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_token_with_missing_sub() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let token = sign_token(&encoding, "fabro-web", 60, None);
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_when_allowed_usernames_empty() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec![]));
|
|
|
|
let token = sign_token(
|
|
&encoding,
|
|
"fabro-web",
|
|
60,
|
|
Some("https://github.com/brynary"),
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_wrong_issuer() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let token = sign_token(
|
|
&encoding,
|
|
"wrong-issuer",
|
|
60,
|
|
Some("https://github.com/brynary"),
|
|
);
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_expired_token() {
|
|
let (encoding, decoding) = generate_test_keypair();
|
|
let app = test_router(jwt_mode(decoding, vec!["brynary"]));
|
|
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let claims = serde_json::json!({
|
|
"iss": "fabro-web",
|
|
"iat": now - 200,
|
|
"exp": now - 100,
|
|
"sub": "https://github.com/brynary",
|
|
});
|
|
let header = jsonwebtoken::Header::new(Algorithm::EdDSA);
|
|
let token = jsonwebtoken::encode(&header, &claims, &encoding).unwrap();
|
|
|
|
let req = Request::builder()
|
|
.uri("/test")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_mode_allows_all_requests() {
|
|
let app = test_router(AuthMode::Disabled);
|
|
|
|
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
|
|
|
|
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),
|
|
validation: Arc::new(jwt_validation()),
|
|
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),
|
|
validation: Arc::new(jwt_validation()),
|
|
allowed_usernames: vec!["brynary".to_string()],
|
|
},
|
|
]);
|
|
let app = test_router(mode);
|
|
|
|
let token = sign_token(
|
|
&encoding,
|
|
"fabro-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);
|
|
}
|
|
}
|