Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/tests/sso_idp_ca.rs

//! **An account's own IdP CA, through the `Sso` service** (owner 2026-10-06:
//! "the account admin may add a PEM CA (or a chain) to their identity
//! provider config; gunnar uses it ONLY for that account's IdP TLS —
//! discovery, JWKS, token; validate the PEM, cap its size, audit the change,
//! show the CA fingerprint").
//!
//! Over the REAL plane and stores; the IdP is edda's fake OpenID provider over
//! loopback http (the test door), and the egress RECORDS which CA each call
//! was asked to trust before handing it on — so what is proved here is what
//! gunnar asks for; that the egress honours it is `sso_egress_private.rs`.
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client; the appliance rows and CI name `sso-egress`.

use std::sync::{Arc, Mutex};

use gunnar_grpc::auth::{AuditLedger, AuditRecord};
use gunnar_grpc::sso::Egress;
use gunnar_grpc::{Caller, ControlPlane, EventBus, ServerConfig, ServiceContext, SsoService};
use gunnar_proto::sso_server::Sso as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use nornir_auth_oidc::fake_op::{self, FakeOp};
use tonic::{Code, Request};

const ORIGIN: &str = "http://localhost:7000";
const SECRET: &str = "oidc-client-secret-NEVER-LOGGED";

#[derive(Default)]
struct Ledger(Mutex<Vec<String>>);

impl AuditLedger for Ledger {
    fn record(&self, r: AuditRecord<'_>) {
        self.0.lock().unwrap().push(format!(
            "{} principal={} scope={} {} {}",
            r.method, r.principal, r.scope, r.decision, r.reason
        ));
    }
}

/// gunnar's egress, recording `(url, how many CA certificates it was asked to trust)`.
struct Recording {
    inner: gunnar_server::sso_egress::UreqEgress,
    seen: Mutex<Vec<(String, usize)>>,
}

impl Egress for Recording {
    fn get(&self, url: &str) -> Result<(u16, String), String> {
        self.seen.lock().unwrap().push((url.to_owned(), 0));
        self.inner.get(url)
    }
    fn post_form(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
    ) -> Result<(u16, String), String> {
        self.seen.lock().unwrap().push((url.to_owned(), 0));
        self.inner.post_form(url, authorization, body)
    }
    fn get_trusting(&self, url: &str, ca: &[Vec<u8>]) -> Result<(u16, String), String> {
        self.seen.lock().unwrap().push((url.to_owned(), ca.len()));
        self.inner.get(url)
    }
    fn post_form_trusting(
        &self,
        url: &str,
        authorization: &str,
        body: &str,
        ca: &[Vec<u8>],
    ) -> Result<(u16, String), String> {
        self.seen.lock().unwrap().push((url.to_owned(), ca.len()));
        self.inner.post_form(url, authorization, body)
    }
}

struct Harness {
    _dir: tempfile::TempDir,
    ctx: ServiceContext,
    ledger: Arc<Ledger>,
    egress: Arc<Recording>,
    op: FakeOp,
}

fn caller(id: &str, machine: bool) -> Caller {
    Caller {
        principal_id: id.to_owned(),
        fingerprint: String::new(),
        machine,
        authenticated: true,
    }
}

/// A call from `caller`'s session, stepped up a moment ago (the identity
/// core's fresh user-verified touch).
fn authed<T>(caller: &Caller, body: T) -> Request<T> {
    let mut r = Request::new(body);
    r.extensions_mut().insert(caller.clone());
    r.extensions_mut().insert(gunnar_grpc::auth::PersonSession {
        stepped_up: true,
        stepped_up_at_unix_ms: None,
    });
    r
}

/// A CA (or, `ca = false`, a leaf): PEM and the SHA-256 fingerprint gunnar shows.
fn minted(ca: bool) -> (String, String) {
    let key = rcgen::KeyPair::generate().expect("a key");
    let mut params =
        rcgen::CertificateParams::new(vec!["idp.acme.example".to_owned()]).expect("params");
    params.not_after = rcgen::date_time_ymd(2040, 1, 1);
    if ca {
        params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
        params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
    }
    let cert = params.self_signed(&key).expect("signed");
    (cert.pem(), gunnar_grpc::sso::saml::fingerprint(cert.der()))
}

impl Harness {
    fn new() -> Harness {
        let dir = tempfile::tempdir().expect("tempdir");
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ledger = Arc::new(Ledger::default());
        let identity = gunnar_server::identity::build(
            dir.path(),
            Arc::clone(&control),
            &ServerConfig::default(),
            false,
        );
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        )
        .with_audit_ledger(ledger.clone())
        .with_identity(identity);
        ctx.sso.set_allow_loopback_http(true);
        let egress = Arc::new(Recording {
            inner: gunnar_server::sso_egress::UreqEgress::new(true),
            seen: Mutex::default(),
        });
        ctx.sso.set_egress(egress.clone());
        ctx.sso.set_egress_on(true);
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .expect("console");
        plane
            .grant("gunnar-ui", "", "", GrantLevel::Admin)
            .expect("the relay is a server admin");
        for account in ["acme", "globex"] {
            plane
                .add_principal(account, account, PrincipalKind::User)
                .expect("account");
        }
        let op = FakeOp::start("gunnar-acme", SECRET).expect("fake OP");
        Harness {
            _dir: dir,
            ctx,
            ledger,
            egress,
            op,
        }
    }

    fn sso(&self) -> SsoService {
        SsoService::new(self.ctx.clone())
    }

    fn provider(&self, account: &str, ca_pem: &str) -> SsoProvider {
        SsoProvider {
            account: account.into(),
            protocol: SsoProtocol::Oidc as i32,
            display_name: format!("{account} corp"),
            domains: vec![format!("{account}.example")],
            oidc: Some(OidcSettings {
                issuer: self.op.issuer().to_owned(),
                client_id: "gunnar-acme".into(),
                idp_ca_pem: ca_pem.into(),
                ..Default::default()
            }),
            console_origin: ORIGIN.into(),
            ..Default::default()
        }
    }

    async fn configure(
        &self,
        account: &str,
        ca_pem: &str,
        clear: bool,
    ) -> Result<ProviderView, tonic::Status> {
        self.sso()
            .set_provider(authed(
                &caller(account, false),
                SetProviderRequest {
                    provider: Some(self.provider(account, ca_pem)),
                    client_secret: SECRET.into(),
                    clear_client_secret: false,
                    clear_idp_ca: clear,
                },
            ))
            .await
            .map(|r| r.into_inner().view.expect("a view"))
    }

    fn fingerprints(view: &ProviderView) -> Vec<String> {
        view.provider
            .as_ref()
            .and_then(|p| p.oidc.as_ref())
            .map(|o| o.idp_ca_fingerprints.clone())
            .unwrap_or_default()
    }

    fn pem_in(view: &ProviderView) -> String {
        view.provider
            .as_ref()
            .and_then(|p| p.oidc.as_ref())
            .map(|o| o.idp_ca_pem.clone())
            .unwrap_or_default()
    }

    fn calls(&self) -> Vec<(String, usize)> {
        std::mem::take(&mut *self.egress.seen.lock().unwrap())
    }
}

/// ★ **Stored, shown as its fingerprint, kept, cleared — and audited.**
#[tokio::test(flavor = "multi_thread")]
async fn an_idp_ca_is_stored_shown_by_fingerprint_kept_cleared_and_audited() {
    let h = Harness::new();
    let (ca, fp) = minted(true);
    let view = h
        .configure("acme", &ca, false)
        .await
        .expect("the account admin's CA");
    assert_eq!(
        Harness::fingerprints(&view),
        vec![fp.clone()],
        "the answer names the CA by its fingerprint"
    );
    assert!(
        Harness::pem_in(&view).is_empty(),
        "the answer never carries the CA back"
    );
    let got = h
        .sso()
        .get_provider(authed(
            &caller("acme", false),
            GetProviderRequest {
                account: "acme".into(),
                ..Default::default()
            },
        ))
        .await
        .expect("read")
        .into_inner()
        .view
        .expect("a view");
    assert_eq!(Harness::fingerprints(&got), vec![fp.clone()]);
    assert!(
        h.ledger
            .0
            .lock()
            .unwrap()
            .iter()
            .any(|l| l.starts_with("Sso.SetProvider") && l.contains(&format!("idp_ca={fp} (set)"))),
        "the change is audited with the CA's fingerprint: {:?}",
        h.ledger.0.lock().unwrap()
    );
    // Not resent: kept.
    let kept = h.configure("acme", "", false).await.expect("kept");
    assert_eq!(Harness::fingerprints(&kept), vec![fp.clone()]);
    // Cleared: gone, and audited.
    let cleared = h.configure("acme", "", true).await.expect("cleared");
    assert!(Harness::fingerprints(&cleared).is_empty());
    assert!(
        h.ledger
            .0
            .lock()
            .unwrap()
            .iter()
            .any(|l| l.starts_with("Sso.SetProvider") && l.contains("idp_ca=none (cleared)")),
        "{:?}",
        h.ledger.0.lock().unwrap()
    );
}

/// ★ **What is not a CA is refused, and nothing is stored.**
#[tokio::test(flavor = "multi_thread")]
async fn a_pem_that_is_not_a_ca_is_refused_and_nothing_is_stored() {
    let h = Harness::new();
    let (leaf, _) = minted(false);
    for (bad, words) in [
        (leaf.as_str(), "not a CA"),
        (
            "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
            "certificate",
        ),
        ("hello", "no certificate"),
    ] {
        let err = h
            .configure("globex", bad, false)
            .await
            .expect_err("refused");
        assert_eq!(err.code(), Code::InvalidArgument, "{}", err.message());
        assert!(err.message().contains(words), "{words}: {}", err.message());
    }
    let none = h
        .sso()
        .get_provider(authed(
            &caller("globex", false),
            GetProviderRequest {
                account: "globex".into(),
                ..Default::default()
            },
        ))
        .await
        .expect("read")
        .into_inner()
        .view;
    assert!(none.is_none(), "a refused provider was stored");
}

/// ★ **The account's CA for ITS IdP's calls — discovery, keys, token — and
/// never for another account's**, though both name the same issuer.
#[tokio::test(flavor = "multi_thread")]
async fn the_ca_is_asked_for_its_accounts_idp_calls_only() {
    let h = Harness::new();
    let (ca, _) = minted(true);
    h.configure("acme", &ca, false)
        .await
        .expect("acme, with its CA");
    let configured = h.calls();
    assert!(
        configured.len() >= 2 && configured.iter().all(|(_, n)| *n == 1),
        "acme's discovery and keys trust its CA: {configured:?}"
    );
    h.configure("globex", "", false)
        .await
        .expect("globex, no CA");
    let theirs = h.calls();
    assert!(
        !theirs.is_empty() && theirs.iter().all(|(_, n)| *n == 0),
        "globex's calls trust no CA of acme's: {theirs:?}"
    );
    // A whole sign-in for acme: the token POST trusts its CA too.
    h.op.set_person(fake_op::Person::new("00u-bob", "bob@acme.example", "Bob"));
    let begun = h
        .sso()
        .begin(authed(
            &caller("gunnar-ui", true),
            BeginRequest {
                account: "acme".into(),
                login_hint: String::new(),
                console_origin: ORIGIN.into(),
                client: String::new(),
            },
        ))
        .await
        .expect("begun")
        .into_inner();
    let back =
        h.op.authorize(&begun.redirect_url)
            .expect("the fake signs in");
    let code = back
        .split("code=")
        .nth(1)
        .unwrap_or_default()
        .split('&')
        .next()
        .unwrap_or_default()
        .to_owned();
    h.sso()
        .finish(authed(
            &caller("gunnar-ui", true),
            FinishRequest {
                state: begun.state,
                code,
                console_origin: ORIGIN.into(),
                ..Default::default()
            },
        ))
        .await
        .expect("bob signs in");
    let signed_in = h.calls();
    assert!(
        signed_in
            .iter()
            .any(|(u, n)| u.contains("token") && *n == 1),
        "the token POST trusts acme's CA: {signed_in:?}"
    );
    assert!(signed_in.iter().all(|(_, n)| *n == 1), "{signed_in:?}");
}

/// ★ **A34-41: a SAML provider's metadata URL is read trusting the account's IdP CA**, as an
/// OIDC provider's calls are: the CA is the account's (`oidc.idp_ca_pem`, the one CA field),
/// asked for the metadata fetch, stored, shown by its fingerprint and kept on a re-save.
#[tokio::test(flavor = "multi_thread")]
async fn a34_41_the_saml_metadata_url_is_read_trusting_the_accounts_ca() {
    let h = Harness::new();
    let idp = nornir_auth_saml::fake_idp::FakeIdp::start().expect("fake IdP");
    let (ca, fp) = minted(true);
    let saml = |ca_pem: &str| SsoProvider {
        account: "acme".into(),
        protocol: SsoProtocol::Saml as i32,
        display_name: "Acme SAML".into(),
        saml: Some(SamlSettings {
            idp_metadata_url: idp.metadata_url(),
            ..Default::default()
        }),
        oidc: Some(OidcSettings {
            idp_ca_pem: ca_pem.into(),
            ..Default::default()
        }),
        console_origin: ORIGIN.into(),
        ..Default::default()
    };
    async fn set(
        h: &Harness,
        provider: SsoProvider,
    ) -> Result<tonic::Response<SetProviderResponse>, tonic::Status> {
        h.sso()
            .set_provider(authed(
                &caller("acme", false),
                SetProviderRequest {
                    provider: Some(provider),
                    client_secret: String::new(),
                    clear_client_secret: false,
                    clear_idp_ca: false,
                },
            ))
            .await
    }
    let view = set(&h, saml(&ca))
        .await
        .expect("the metadata URL behind acme's CA")
        .into_inner()
        .view
        .expect("a view");
    assert_eq!(
        view.provider
            .as_ref()
            .unwrap()
            .saml
            .as_ref()
            .unwrap()
            .idp_entity_id,
        idp.entity_id()
    );
    let calls = h.calls();
    assert!(
        calls
            .iter()
            .any(|(u, n)| u == &idp.metadata_url() && *n == 1),
        "the metadata GET trusts acme's CA: {calls:?}"
    );
    assert_eq!(
        Harness::fingerprints(&view),
        vec![fp.clone()],
        "the CA is shown by its fingerprint"
    );
    assert!(
        h.ledger
            .0
            .lock()
            .unwrap()
            .iter()
            .any(|l| l.starts_with("Sso.SetProvider") && l.contains(&format!("idp_ca={fp} (set)"))),
        "{:?}",
        h.ledger.0.lock().unwrap()
    );
    // Saved again with the URL and no CA pasted: the stored CA is kept and trusted again.
    let again = set(&h, saml(""))
        .await
        .expect("saved again")
        .into_inner()
        .view
        .expect("a view");
    assert_eq!(Harness::fingerprints(&again), vec![fp]);
    let calls = h.calls();
    assert!(
        calls
            .iter()
            .any(|(u, n)| u == &idp.metadata_url() && *n == 1),
        "the kept CA is trusted again: {calls:?}"
    );
}