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_without_an_idp_dials_nothing.rs

//! **AIRGAP B4: company SSO dials an identity provider only once one is configured** (owner
//! rule 2026-10-25: outbound calls need an explicit switch; for SSO the switch is an account
//! admin configuring an IdP). With none configured, the visitor's doors (`Sso.Discover`,
//! `Sso.Begin`) answer without the egress being asked at all, so nothing is dialled: no
//! discovery document, no keys, no token endpoint.

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

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 tonic::Request;

/// An egress that records every call and dials nothing.
#[derive(Default)]
struct Counting(Mutex<Vec<String>>);

impl Counting {
    fn ask(&self, url: &str) -> Result<(u16, String), String> {
        self.0.lock().unwrap().push(url.to_owned());
        Err("this egress dials nothing".to_owned())
    }
}

impl Egress for Counting {
    fn get(&self, url: &str) -> Result<(u16, String), String> {
        self.ask(url)
    }
    fn post_form(
        &self,
        url: &str,
        _authorization: &str,
        _body: &str,
    ) -> Result<(u16, String), String> {
        self.ask(url)
    }
    fn get_trusting(&self, url: &str, _ca: &[Vec<u8>]) -> Result<(u16, String), String> {
        self.ask(url)
    }
    fn post_form_trusting(
        &self,
        url: &str,
        _authorization: &str,
        _body: &str,
        _ca: &[Vec<u8>],
    ) -> Result<(u16, String), String> {
        self.ask(url)
    }
}

fn relay<T>(body: T) -> Request<T> {
    let mut r = Request::new(body);
    r.extensions_mut().insert(Caller {
        principal_id: "gunnar-ui".to_owned(),
        fingerprint: String::new(),
        machine: true,
        authenticated: true,
    });
    r
}

#[tokio::test(flavor = "multi_thread")]
async fn with_no_idp_configured_the_sign_in_doors_dial_nothing() {
    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 ctx = ServiceContext::new(
        control,
        Arc::new(EventBus::new(16)),
        ServerConfig::default(),
    );
    let egress = Arc::new(Counting::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");
    plane
        .add_principal("acme", "acme", PrincipalKind::User)
        .expect("an account with no IdP");
    let sso = SsoService::new(ctx.clone());

    for hint in ["", "bob@acme.example", "acme", "acme.example"] {
        let found = sso
            .discover(relay(DiscoverRequest { hint: hint.into() }))
            .await
            .expect("discover answers")
            .into_inner();
        assert!(!found.found && !found.any_provider, "{hint:?}: {found:?}");
    }
    let begun = sso
        .begin(relay(BeginRequest {
            account: "acme".into(),
            login_hint: "bob@acme.example".into(),
            console_origin: "http://localhost:7000".into(),
            client: String::new(),
        }))
        .await;
    assert!(
        begun.is_err(),
        "a sign-in with no IdP configured is refused: {begun:?}"
    );
    assert!(
        egress.0.lock().unwrap().is_empty(),
        "the egress was asked: {:?}",
        egress.0.lock().unwrap()
    );
}

/// ★ **A configured IdP is dialled only while the operator's switch is on** (A34-16,
/// default = AIRGAP): with it off, an account admin's issuer is never read and the sign-in
/// is refused by name (`[sso-egress-off]`); switched on, the same sign-in reads the IdP when
/// `sso` is compiled in, and is refused by name with nothing dialled when it is not (default).
#[tokio::test(flavor = "multi_thread")]
async fn with_the_operators_switch_off_a_configured_idp_is_never_dialled() {
    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 ctx = ServiceContext::new(
        control,
        Arc::new(EventBus::new(16)),
        ServerConfig::default(),
    );
    let egress = Arc::new(Counting::default());
    ctx.sso.set_egress(egress.clone());
    plane
        .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
        .expect("console");
    plane
        .grant("gunnar-ui", "", "", GrantLevel::Admin)
        .expect("the relay is a server admin");
    plane
        .add_principal("acme", "acme", PrincipalKind::User)
        .expect("an account");
    let provider = SsoProvider {
        account: "acme".into(),
        protocol: SsoProtocol::Oidc as i32,
        display_name: "Acme".into(),
        oidc: Some(OidcSettings {
            issuer: "https://idp.acme.example".into(),
            client_id: "c".into(),
            ..Default::default()
        }),
        console_origin: "https://console.example".into(),
        ..Default::default()
    };
    plane
        .sso()
        .expect("an identity table")
        .set_provider(
            provider,
            gunnar_grpc::sso::plane::SecretChange::Keep,
            "acme",
        )
        .expect("stored");
    let sso = SsoService::new(ctx.clone());
    let ask = || {
        relay(BeginRequest {
            account: "acme".into(),
            login_hint: String::new(),
            console_origin: "https://console.example".into(),
            client: String::new(),
        })
    };
    let refused = sso
        .begin(ask())
        .await
        .expect_err("refused while the switch is off");
    assert!(
        refused.message().contains("[sso-egress-off]"),
        "{refused:?}"
    );
    assert!(
        egress.0.lock().unwrap().is_empty(),
        "the egress was asked: {:?}",
        egress.0.lock().unwrap()
    );
    ctx.sso.set_egress_on(true);
    let begun = sso.begin(ask()).await;
    // With `sso` compiled in, the switch is what stands between the issuer and the network.
    #[cfg(feature = "sso")]
    {
        let _ = begun;
        assert!(
            !egress.0.lock().unwrap().is_empty(),
            "switched on, the sign-in reads the IdP"
        );
    }
    // The DEFAULT build (no `sso`, AIRGAP) links no protocol at all: even switched on, the
    // sign-in is refused by name and still nothing is dialled. This half used to assert the
    // `sso` build's dial in every build, so a default `cargo test` failed on the one build
    // that is right not to dial (gunnar-grpc `src/sso/oidc.rs` `off::begin`).
    #[cfg(not(feature = "sso"))]
    {
        let refused = begun.expect_err("no protocol is compiled into the default build");
        assert!(
            refused.message().contains("not compiled into this build"),
            "{refused:?}"
        );
        assert!(
            egress.0.lock().unwrap().is_empty(),
            "the default build asked the egress: {:?}",
            egress.0.lock().unwrap()
        );
    }
}