crates/nornir-auth-mtls/src/lib.rs
//! # nornir-auth-mtls
//!
//! The **mTLS (client-certificate) auth provider** — the transport-level method
//! that maps a mutual-TLS leaf-certificate Common Name to an identity. It is the
//! sibling of the bearer providers ([`nornir-auth-oidc`], [`nornir-auth-bearer`])
//! in the shared edda auth family: every MODERN method is a pluggable sub-crate
//! implementing the ONE shared [`nornir_service::AuthProvider`] seam so a server
//! holds a single `Vec<Box<dyn AuthProvider>>` and dispatches through it.
//!
//! ## Trust model — read this (M12 / P18)
//!
//! This provider does **not** verify a certificate chain and does **not**
//! independently path-validate a CA. Cryptographic client-cert trust — that the
//! presented leaf chains to a trusted CA — is established ENTIRELY by the TLS
//! transport during the mutual-TLS handshake (rustls' `client_ca` /
//! `with_client_cert_verifier`). By the time this provider runs, the handshake
//! has already succeeded and the TLS layer has EXTRACTED the leaf's Common Name.
//! The provider's only job is the identity mapping `CN → Principal{ method:
//! "mtls" }`. The retained [`MtlsProvider::ca_cert`] is documentation of the
//! operator's intended CA, surfaced by [`MtlsProvider::ca_trust_warning`] so a
//! server can shout at boot that the transport `client_ca` must equal it — a
//! broader `client_ca` admits any cert valid under it. This is why the provider
//! links no crypto/TLS/HTTP stack: it is the leanest provider of all, and a tier
//! pins it whenever the server terminates mTLS.
//!
//! ```
//! use nornir_auth_mtls::MtlsProvider;
//! use nornir_service::provider::AuthProvider;
//! # async fn demo() {
//! let p = MtlsProvider::new(String::new());
//! // A bearer credential is never this provider's — it speaks client certs only.
//! assert!(p.verify_bearer("some.jwt.token").await.unwrap().is_none());
//! // The extracted leaf CN becomes the identity.
//! let id = p.verify_client_cert("CN=agent-07").await.unwrap().unwrap();
//! assert_eq!(id.subject, "CN=agent-07");
//! assert_eq!(id.method, "mtls");
//! # }
//! ```
use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use tonic::async_trait;
/// A client-certificate (mTLS) auth provider. Holds the operator-declared trusted
/// CA PEM purely for the boot-time trust warning — it is NOT used to validate the
/// chain (the TLS transport does that during the handshake). On presentation of a
/// CN already extracted by the TLS layer, the provider yields a [`Principal`]
/// whose `subject` is that CN and `method` is `"mtls"`.
#[derive(Debug, Clone, Default)]
pub struct MtlsProvider {
/// Operator-declared trusted CA PEM for client certs. Documentation only:
/// client-cert trust is delegated to the transport `client_ca`. See
/// [`MtlsProvider::ca_trust_warning`].
ca_cert: String,
}
impl MtlsProvider {
/// Build from the configured (documentation-only) CA PEM. Pass `String::new()`
/// when the CA is declared solely at the transport.
pub fn new(ca_cert: impl Into<String>) -> Self {
Self { ca_cert: ca_cert.into() }
}
/// The operator-declared CA PEM (documentation only — never path-validated
/// here; see the crate trust-model note).
pub fn ca_cert(&self) -> &str {
&self.ca_cert
}
/// The boot-time SECURITY warning to emit when a per-method `ca_cert` is
/// configured (M12 / P18): the per-method CA is NOT independently enforced —
/// the transport `client_ca` (TLS handshake) is authoritative for client-cert
/// trust. `None` when no CA PEM is declared (nothing to warn about). A server
/// logs this at startup so the operator knows `client_ca` must equal the
/// intended CA.
pub fn ca_trust_warning(&self) -> Option<&'static str> {
if self.ca_cert.trim().is_empty() {
None
} else {
Some(
"SECURITY (mTLS): the per-method `ca_cert` is NOT independently \
enforced — the transport `client_ca` (TLS handshake) is authoritative \
for client-cert trust. Ensure `client_ca` equals this method's intended \
CA; a broader `client_ca` admits any cert valid under it.",
)
}
}
/// Map an already-extracted leaf-cert CN to a [`Principal`]. `None` CN ⇒
/// `Ok(None)` ("no client-cert identity presented" — the caller falls through
/// to unauthorized). This is the shared decision the bearer-dispatch path
/// reaches for via [`AuthProvider::verify_client_cert`].
pub fn principal_for_cn(&self, cn: Option<&str>) -> Option<Principal> {
cn.map(|cn| Principal::new(cn.to_string(), "mtls"))
}
}
#[async_trait]
impl AuthProvider for MtlsProvider {
fn name(&self) -> &str {
"mtls"
}
/// mTLS is a transport method, never a bearer method — a presented bearer is
/// always "not mine" so the dispatcher tries the bearer providers.
async fn verify_bearer(&self, _token: &str) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
/// The extracted leaf CN becomes the identity (`method = "mtls"`). Trust in the
/// cert itself was already established by the TLS handshake (see the crate
/// trust-model note); here a non-empty CN always maps to a principal.
async fn verify_client_cert(&self, cn: &str) -> Result<Option<Principal>, ProviderError> {
Ok(Some(Principal::new(cn.to_string(), "mtls")))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The extracted CN becomes the identity with method `"mtls"`; a bearer is
/// never this provider's. RED-when-broken: the mTLS identity mapping is the
/// whole job of this crate.
#[tokio::test]
async fn cn_becomes_identity_bearer_is_not_mine() {
let p = MtlsProvider::new(String::new());
let id = p.verify_client_cert("CN=agent-07").await.unwrap().expect("cn identity");
assert_eq!(id.subject, "CN=agent-07");
assert_eq!(id.method, "mtls");
// A bearer credential is always "not mine" — mTLS is transport-level.
assert!(p.verify_bearer("any.jwt.here").await.unwrap().is_none());
}
/// `principal_for_cn` mirrors the trait method and handles the "no CN
/// presented" case as `None` (the caller then falls through to unauthorized).
#[test]
fn principal_for_cn_handles_absent_cn() {
let p = MtlsProvider::new(String::new());
assert_eq!(p.principal_for_cn(Some("CN=x")).unwrap().subject, "CN=x");
assert!(p.principal_for_cn(None).is_none(), "no CN ⇒ no identity");
}
/// The CA trust warning fires only when a per-method CA PEM is declared —
/// documenting that the transport `client_ca` is authoritative (M12).
#[test]
fn ca_trust_warning_only_when_ca_declared() {
assert!(MtlsProvider::new(String::new()).ca_trust_warning().is_none());
assert!(MtlsProvider::new(" ").ca_trust_warning().is_none(), "blank CA ⇒ no warning");
assert!(MtlsProvider::new("-----BEGIN CERTIFICATE-----\n...").ca_trust_warning().is_some());
}
}