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

//! **Company SSO sign-in through the real entry point** (rows 45/46/77: domains, JIT,
//! rebind, the discovery door). `tests/sso.rs` drives the same verbs by calling
//! `SsoService` with a `Caller` put in by hand; here the `Sso` service is the one a box
//! serves: the real gRPC router with its gate in front, over the real
//! `CoreControlPlane` on a real store, and every call carries a credential the gate
//! must verify. The identity providers are edda's fakes, the ones `tests/sso.rs` uses
//! (`nornir_auth_oidc::fake_op`, `nornir_auth_saml::fake_idp`), reached over real HTTP
//! through gunnar's own egress.
//!
//! * OIDC: the account admin's stepped-up passkey session sets the provider and proves
//!   its domain; the console (a server admin, its signed key) discovers, begins and
//!   finishes; the IdP's person becomes a NEW employee just in time, and the session the
//!   finish hands is a credential the gate takes as that employee (`Access.WhoAmI`).
//!   The second sign-in is the same person. An IdP migration is refused until the
//!   admin opens a rebind; JIT off makes nobody new. The relay's door is the console's.
//! * SAML: the same through a SAML IdP; a replayed response and an unsigned one are
//!   refused and create nobody.
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use gunnar_core::ssh_key::PrivateKey;
use gunnar_grpc::auth::SESSION_METADATA_KEY;
use gunnar_grpc::client_signer::{authed, plain_channel, ControlChannel};
use gunnar_grpc::{ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::sso_client::SsoClient;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use nornir_auth_oidc::fake_op::{self, FakeOp};
use nornir_auth_saml::fake_idp::{self, FakeIdp};
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};

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

fn free_addr() -> SocketAddr {
    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    l.local_addr().expect("addr")
}

fn mint() -> (PrivateKey, String) {
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let k = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
    let line = k.public_key().to_openssh().expect("line");
    (k, line)
}

/// A software passkey (the one `tests/sso.rs` uses): P-256, user-verified.
struct Authenticator {
    key: p256::ecdsa::SigningKey,
    id: Vec<u8>,
}

impl Authenticator {
    fn new(seed: u8, id: &[u8]) -> Authenticator {
        Authenticator {
            key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).unwrap(),
            id: id.to_vec(),
        }
    }
    fn public(&self) -> Vec<u8> {
        self.key
            .verifying_key()
            .to_sec1_point(false)
            .as_bytes()
            .to_vec()
    }
    fn assert(&self, challenge: &[u8], counter: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
        let cd = format!(
            r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
            gunnar_grpc::webauthn::b64url(challenge)
        )
        .into_bytes();
        let mut ad = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
        // UP | UV: a user-verified touch, so the session it mints has stepped up.
        ad.push(0x05);
        ad.extend_from_slice(&counter.to_be_bytes());
        let mut msg = ad.clone();
        msg.extend_from_slice(&sha2::Sha256::digest(&cd));
        let sig: p256::ecdsa::Signature = self.key.sign(&msg);
        (cd, ad, sig.to_der().as_bytes().to_vec())
    }
}

/// A user-verified passkey login through the identity core, as the console relays it:
/// the session token.
fn login(identity: &gunnar_grpc::identity::GunnarIdentity, by: &Authenticator) -> String {
    let relay = nornir_identity::Relay {
        rp_id: RP_ID.into(),
        origin: ORIGIN.into(),
        client: None,
    };
    let c = identity.login_start(&relay).expect("login start");
    let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
    let (cd, ad, sig) = by.assert(&challenge, 1);
    identity
        .login_finish(nornir_identity::Assertion {
            ceremony_id: c.id,
            credential_id: by.id.clone(),
            client_data_json: cd,
            authenticator_data: ad,
            signature: sig,
            rp_id: RP_ID.into(),
            origin: ORIGIN.into(),
        })
        .expect("the passkey logs in")
        .token
}

/// A call carrying a person's identity-core session, as the console sends it.
fn with_session<T>(token: &str, body: T) -> Request<T> {
    let mut r = Request::new(body);
    r.metadata_mut()
        .insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
    r
}

fn query(url: &str, key: &str) -> String {
    url.split_once('?')
        .map(|(_, q)| q)
        .unwrap_or("")
        .split('&')
        .filter_map(|p| p.split_once('='))
        .find(|(k, _)| *k == key)
        .map(|(_, v)| {
            let mut out = Vec::new();
            let b = v.as_bytes();
            let mut i = 0;
            while i < b.len() {
                if b[i] == b'%' && i + 2 < b.len() {
                    out.push(u8::from_str_radix(&v[i + 1..i + 3], 16).unwrap());
                    i += 3;
                } else {
                    out.push(if b[i] == b'+' { b' ' } else { b[i] });
                    i += 1;
                }
            }
            String::from_utf8(out).unwrap()
        })
        .unwrap_or_default()
}

/// A DNS-over-HTTPS JSON resolver on the loopback, answering `records` as the TXT
/// strings of whatever name is asked. Its URL.
fn doh_server(records: Vec<String>) -> String {
    use std::io::{Read as _, Write as _};
    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port");
    let url = format!("http://{}/resolve", listener.local_addr().unwrap());
    let answers: Vec<serde_json::Value> = records
        .iter()
        .map(|r| serde_json::json!({ "type": 16, "data": format!("\"{r}\"") }))
        .collect();
    let body = serde_json::json!({ "Status": 0, "Answer": answers }).to_string();
    std::thread::spawn(move || {
        for stream in listener.incoming().flatten() {
            let mut stream = stream;
            let mut buf = [0u8; 4096];
            let _ = stream.read(&mut buf);
            let _ = write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Type: application/dns-json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
        }
    });
    url
}

/// One box: the real router and gate on a real store, the console's signed key (the
/// box's first admin), and acme's and globex's admins, each with a passkey.
struct Rig {
    _dir: tempfile::TempDir,
    rt: tokio::runtime::Runtime,
    stores: Arc<ServerStores>,
    plane: Arc<CoreControlPlane>,
    ctx: ServiceContext,
    at: String,
    console: PrivateKey,
    acme: String,
    globex: String,
    stop: Option<tokio::sync::oneshot::Sender<()>>,
}

impl Drop for Rig {
    fn drop(&mut self) {
        if let Some(stop) = self.stop.take() {
            let _ = stop.send(());
        }
    }
}

impl Rig {
    fn start() -> Rig {
        let dir = tempfile::tempdir().unwrap();
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let (console, console_line) = mint();
        stores
            .auth()
            .bootstrap_admin(&console_line)
            .expect("the first admin");
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        let control: Arc<dyn ControlPlane> = plane.clone();
        let acme_key = Authenticator::new(6, b"acme-key");
        let globex_key = Authenticator::new(7, b"globex-key");
        for (account, key) in [("acme", &acme_key), ("globex", &globex_key)] {
            plane
                .add_principal(account, account, PrincipalKind::User)
                .expect("account");
            plane
                .add_credential(account, &key.id, &key.public(), account, 0)
                .expect("the admin's passkey");
        }
        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_identity(Arc::clone(&identity));
        // The box's egress to its IdP, as `gunnar-server` wires it, with the operator's
        // switch on (A34-16) and the fakes' plain-HTTP loopback allowed.
        ctx.sso.set_allow_loopback_http(true);
        ctx.sso
            .set_egress(Arc::new(gunnar_server::sso_egress::UreqEgress::new(true)));
        ctx.sso.set_egress_on(true);
        let rt = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(4)
            .enable_all()
            .build()
            .expect("rt");
        let addr = free_addr();
        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
        let served = ctx.clone();
        rt.spawn(async move {
            let _ = gunnar_grpc::router(served, || 0)
                .serve_with_shutdown(addr, async {
                    let _ = rx.await;
                })
                .await;
        });
        let t0 = Instant::now();
        while std::net::TcpStream::connect(addr).is_err() {
            assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
            std::thread::sleep(Duration::from_millis(25));
        }
        let acme = login(&identity, &acme_key);
        let globex = login(&identity, &globex_key);
        Rig {
            _dir: dir,
            rt,
            stores,
            plane,
            ctx,
            at: addr.to_string(),
            console,
            acme,
            globex,
            stop: Some(tx),
        }
    }

    fn sso(&self) -> SsoClient<ControlChannel> {
        SsoClient::new(plain_channel(&self.at).expect("a channel"))
    }

    fn access(&self) -> AccessClient<ControlChannel> {
        AccessClient::new(plain_channel(&self.at).expect("a channel"))
    }

    fn employees(&self, account: &str) -> Vec<String> {
        let mut ids: Vec<String> = self
            .plane
            .employees(account)
            .expect("employees")
            .into_iter()
            .map(|p| p.id)
            .collect();
        ids.sort();
        ids
    }

    /// Begin, as the console relays it.
    async fn begin(&self, account: &str) -> BeginResponse {
        self.sso()
            .begin(authed(
                &self.console,
                BeginRequest {
                    account: account.into(),
                    login_hint: String::new(),
                    console_origin: ORIGIN.into(),
                    client: String::new(),
                },
            ))
            .await
            .expect("Sso.Begin through the gate")
            .into_inner()
    }

    async fn finish(&self, request: FinishRequest) -> Result<FinishResponse, tonic::Status> {
        self.sso()
            .finish(authed(&self.console, request))
            .await
            .map(|r| r.into_inner())
    }

    /// One whole OIDC sign-in as `who` through the fake OP, relayed by the console.
    async fn oidc_login(
        &self,
        op: &FakeOp,
        who: fake_op::Person,
    ) -> Result<FinishResponse, tonic::Status> {
        op.set_person(who);
        let begun = self.begin("acme").await;
        assert_eq!(begun.protocol, SsoProtocol::Oidc as i32);
        let back = op
            .authorize(&begun.redirect_url)
            .expect("the fake signs in");
        assert_eq!(query(&back, "state"), begun.state, "the state comes back");
        self.finish(FinishRequest {
            state: begun.state,
            code: query(&back, "code"),
            console_origin: ORIGIN.into(),
            ..Default::default()
        })
        .await
    }

    /// Who the gate takes `session` for: `Access.WhoAmI` through the router.
    async fn who(&self, session: &str) -> Result<String, tonic::Status> {
        self.access()
            .who_am_i(with_session(session, WhoAmIRequest {}))
            .await
            .map(|r| r.into_inner().principal.map(|p| p.id).unwrap_or_default())
    }

    async fn set_provider(
        &self,
        session: &str,
        provider: SsoProvider,
    ) -> Result<ProviderView, tonic::Status> {
        self.sso()
            .set_provider(with_session(
                session,
                SetProviderRequest {
                    provider: Some(provider),
                    client_secret: SECRET.into(),
                    clear_client_secret: false,
                    clear_idp_ca: false,
                },
            ))
            .await
            .map(|r| r.into_inner().view.expect("a view"))
    }

    async fn discover(&self, hint: &str) -> DiscoverResponse {
        self.sso()
            .discover(authed(&self.console, DiscoverRequest { hint: hint.into() }))
            .await
            .expect("Sso.Discover through the gate")
            .into_inner()
    }

    /// The account's own log, as `kind:actor:role`.
    fn account_log(&self, account: &str) -> Vec<String> {
        self.stores
            .account_audit()
            .query(
                account,
                &gunnar_core::account_audit::AuditQuery {
                    limit: 200,
                    ..Default::default()
                },
            )
            .expect("the account's log")
            .entries
            .iter()
            .map(|e| format!("{}:{}:{}", e.kind, e.actor, e.actor_role))
            .collect()
    }
}

fn bob() -> fake_op::Person {
    fake_op::Person::new("00u-bob", "bob@acme.example", "Bob Example")
}

/// ★ **OIDC sign-in through the router and gate**: the provider, the domain proof,
/// discovery, JIT, the session as a credential, the rebind, JIT off.
#[test]
fn an_oidc_sign_in_goes_through_the_real_router_and_gate() {
    let b = Rig::start();
    let op = FakeOp::start("gunnar-acme", SECRET).expect("fake OP");
    let provider = |jit: SsoJit| SsoProvider {
        account: "acme".into(),
        protocol: SsoProtocol::Oidc as i32,
        display_name: "acme corp".into(),
        domains: vec!["acme.example".into()],
        oidc: Some(OidcSettings {
            issuer: op.issuer().to_owned(),
            client_id: "gunnar-acme".into(),
            ..Default::default()
        }),
        console_origin: ORIGIN.into(),
        jit: jit as i32,
        ..Default::default()
    };
    b.rt.block_on(async {
        // The discovery door before any provider: nobody, and no account named.
        let none = b.discover("bob@acme.example").await;
        assert!(
            !none.any_provider && !none.found && none.account.is_empty(),
            "{none:?}"
        );

        // Only acme's admin sets acme's provider: globex's admin, stepped up, is refused,
        // and so is a call with no credential.
        let outsider = b
            .set_provider(&b.globex, provider(SsoJit::Open))
            .await
            .unwrap_err();
        assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
        let anon = b
            .sso()
            .set_provider(Request::new(SetProviderRequest {
                provider: Some(provider(SsoJit::Open)),
                ..Default::default()
            }))
            .await
            .unwrap_err();
        assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
        let view = b
            .set_provider(&b.acme, provider(SsoJit::Open))
            .await
            .expect("Sso.SetProvider through the gate");
        assert!(view.provider.as_ref().expect("provider").has_client_secret);
        assert!(
            !format!("{view:?}").contains(SECRET),
            "the client secret came back"
        );

        // The domain proof (A34-5) from the operator's resolver: until it holds, the
        // domain leads nowhere; then the hint finds acme.
        assert!(
            !b.discover("bob@acme.example").await.found,
            "an unproven domain led a sign-in"
        );
        let record = gunnar_grpc::sso::domains::proof_value("acme", "acme.example");
        b.ctx.sso.set_dns_resolver(&doh_server(vec![record]));
        let proven = b
            .sso()
            .verify_domain(with_session(
                &b.acme,
                VerifyDomainRequest {
                    account: "acme".into(),
                    domain: "acme.example".into(),
                },
            ))
            .await
            .expect("Sso.VerifyDomain through the gate")
            .into_inner()
            .view
            .expect("a view");
        assert!(proven
            .provider
            .unwrap()
            .verified_domains
            .contains(&"acme.example".to_owned()));
        let found = b.discover("Bob@ACME.example").await;
        assert!(
            found.any_provider
                && found.found
                && found.account == "acme"
                && found.display_name == "acme corp",
            "{found:?}"
        );

        // The relay's door is the console's: the account admin's session is no relay.
        let not_relay = b
            .sso()
            .begin(with_session(
                &b.acme,
                BeginRequest {
                    account: "acme".into(),
                    console_origin: ORIGIN.into(),
                    ..Default::default()
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
        let not_relay = b
            .sso()
            .discover(with_session(
                &b.acme,
                DiscoverRequest {
                    hint: "bob@acme.example".into(),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");

        // JIT: the first sign-in makes a NEW employee of acme, and the session it hands
        // is a credential the gate takes as that employee.
        let first = b.oidc_login(&op, bob()).await.expect("the first sign-in");
        assert!(first.created, "{first:?}");
        let bob_id = first.principal.as_ref().expect("principal").id.clone();
        assert_eq!(bob_id, "acme.bob");
        assert_eq!(first.account, "acme");
        assert_eq!(
            b.stores.auth().employer_of(&bob_id).unwrap().as_deref(),
            Some("acme")
        );
        assert_eq!(
            b.who(&first.session)
                .await
                .expect("the session is a credential"),
            "acme.bob"
        );
        // A session nobody handed out is no credential.
        assert_eq!(
            b.who("not-a-session").await.unwrap_err().code(),
            Code::Unauthenticated
        );

        // The second sign-in is the same person.
        let second = b.oidc_login(&op, bob()).await.expect("the second sign-in");
        assert!(!second.created);
        assert_eq!(second.principal.unwrap().id, bob_id);
        assert_eq!(b.employees("acme"), std::slice::from_ref(&bob_id));

        // An IdP migration (A34-11): a new subject for bob is refused, never re-pointed,
        // until acme's admin opens a rebind; then it binds the SAME employee.
        let mut migrated = fake_op::Person::new("new-idp-bob", "bob@acme.example", "Bob Example");
        migrated.email_verified = Some(true);
        let refused = b
            .oidc_login(&op, migrated.clone())
            .await
            .expect_err("a new subject took bob");
        assert!(
            refused.message().contains("[sso-claim-bound]"),
            "{}",
            refused.message()
        );
        let outsider = b
            .sso()
            .rebind_users(with_session(
                &b.globex,
                RebindUsersRequest {
                    account: "acme".into(),
                    principal_ids: vec![bob_id.clone()],
                    ..Default::default()
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
        let rebound = b
            .sso()
            .rebind_users(with_session(
                &b.acme,
                RebindUsersRequest {
                    account: "acme".into(),
                    principal_ids: vec![bob_id.clone()],
                    ..Default::default()
                },
            ))
            .await
            .expect("Sso.RebindUsers through the gate")
            .into_inner();
        assert!(
            rebound.users.len() == 1 && rebound.users[0].rebind,
            "{:?}",
            rebound.users
        );
        let moved = b
            .oidc_login(&op, migrated)
            .await
            .expect("the rebind lets the new IdP bind bob");
        assert!(!moved.created, "a new employee was made");
        assert_eq!(moved.principal.unwrap().id, bob_id);
        assert_eq!(
            b.who(&moved.session).await.expect("the new session"),
            bob_id
        );

        // JIT off (A34-25): nobody new is made.
        b.set_provider(&b.acme, provider(SsoJit::Off))
            .await
            .expect("JIT off");
        let refused = b
            .oidc_login(
                &op,
                fake_op::Person::new("00u-dan", "dan@acme.example", "Dan Example"),
            )
            .await
            .expect_err("JIT off made an employee");
        assert!(
            refused.message().contains("[sso-jit-off]"),
            "{}",
            refused.message()
        );
        assert_eq!(b.employees("acme"), std::slice::from_ref(&bob_id));
    });

    // The account's own log holds the provider its admin set and each sign-in.
    let log = b.account_log("acme");
    assert!(
        log.iter().any(|s| s == "sso.provider_set:acme:admin"),
        "{log:#?}"
    );
    assert_eq!(
        log.iter().filter(|s| *s == "login:acme.bob:member").count(),
        3,
        "{log:#?}"
    );
}

/// ★ **SAML sign-in through the router and gate**: the metadata read by URL, the
/// employee made just in time, the session a credential; a replayed response and an
/// unsigned one refused, creating nobody.
#[test]
fn a_saml_sign_in_goes_through_the_real_router_and_gate() {
    let b = Rig::start();
    let idp = FakeIdp::start().expect("fake IdP");
    b.rt.block_on(async {
        let provider = SsoProvider {
            account: "acme".into(),
            protocol: SsoProtocol::Saml as i32,
            display_name: "Acme SAML".into(),
            saml: Some(SamlSettings {
                idp_metadata_url: idp.metadata_url(),
                username_attribute: "email".into(),
                ..Default::default()
            }),
            console_origin: ORIGIN.into(),
            ..Default::default()
        };
        let view = b
            .set_provider(&b.acme, provider)
            .await
            .expect("Sso.SetProvider through the gate");
        let saml = view.provider.unwrap().saml.unwrap();
        assert_eq!(
            saml.idp_entity_id,
            idp.entity_id(),
            "the metadata was read by URL"
        );
        assert_eq!(view.acs_url, format!("{ORIGIN}/auth/sso/saml/acs"));

        let begun = b.begin("acme").await;
        assert_eq!(begun.protocol, SsoProtocol::Saml as i32);
        let posted = idp.respond(&begun.redirect_url).expect("the IdP answers");
        assert_eq!(posted.relay_state, begun.state);
        let saml_finish = |state: &str, response: &str| FinishRequest {
            state: state.into(),
            saml_response: response.into(),
            console_origin: ORIGIN.into(),
            ..Default::default()
        };
        // The relay's door is the console's.
        let not_relay = b
            .sso()
            .finish(with_session(
                &b.acme,
                saml_finish(&posted.relay_state, &posted.saml_response),
            ))
            .await
            .unwrap_err();
        assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
        let ok = b
            .finish(saml_finish(&posted.relay_state, &posted.saml_response))
            .await
            .expect("a signed response");
        assert!(ok.created, "{ok:?}");
        let carol = ok.principal.expect("principal").id;
        assert_eq!(carol, "acme.carol");
        assert_eq!(
            b.who(&ok.session)
                .await
                .expect("the session is a credential"),
            carol
        );

        // The same response under a fresh state: refused.
        let again = b.begin("acme").await;
        let replay = b
            .finish(saml_finish(&again.state, &posted.saml_response))
            .await
            .expect_err("a replayed response");
        assert_eq!(replay.code(), Code::PermissionDenied, "{replay:?}");
        // An unsigned response: refused.
        idp.tweak_next(fake_idp::Tweak::Unsigned);
        let begun = b.begin("acme").await;
        let unsigned = idp.respond(&begun.redirect_url).expect("the IdP answers");
        let refused = b
            .finish(saml_finish(&unsigned.relay_state, &unsigned.saml_response))
            .await
            .expect_err("an unsigned response");
        assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    });
    assert_eq!(b.employees("acme"), ["acme.carol".to_owned()]);
    assert!(
        b.account_log("acme")
            .iter()
            .any(|s| s == "login:acme.carol:member"),
        "{:#?}",
        b.account_log("acme")
    );
}