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/examples/fake_idp.rs

//! **A fake company identity provider, as a process** — for the console's
//! rung-1 uitest and the estate-box gate, which drive a real browser (or a
//! real HTTP client) through a single sign-on and must never reach a real IdP.
//!
//! It runs edda's in-process fakes over real HTTP on loopback:
//! `nornir_auth_oidc::fake_op` (an OpenID provider: discovery, JWKS,
//! `/authorize`, `/token` with PKCE) and `nornir_auth_saml::fake_idp` (a SAML
//! IdP: metadata, `/sso` answering with the auto-submitting POST form). Both
//! sign with throwaway TEST keys; nothing here is a credential.
//!
//! ```text
//! fake_idp --client-id <id> --client-secret <secret> [--https [--oidc-bind <ip>]]
//! ```
//!
//! prints ONE line of JSON on stdout — `oidc_issuer` (and with `--https` its throwaway
//! CA, `oidc_ca_pem`), `saml_metadata_url`,
//! `saml_entity_id`, `control` — and serves until killed. The control door
//! (`GET <control>/person?sub=&email=&name=`, `GET <control>/tweak?oidc=&saml=`)
//! changes who the fakes sign in next, or breaks one thing in the next answer.
//! The client secret is the caller's, and is never printed.

use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;

use nornir_auth_oidc::fake_op::{self, FakeOp};
use nornir_auth_saml::fake_idp::{self, FakeIdp};

fn arg(name: &str) -> Option<String> {
    let args: Vec<String> = std::env::args().collect();
    args.iter()
        .position(|a| a == name)
        .and_then(|i| args.get(i + 1))
        .cloned()
}

fn decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < b.len() => match std::str::from_utf8(&b[i + 1..i + 3])
                .ok()
                .and_then(|h| u8::from_str_radix(h, 16).ok())
            {
                Some(v) => {
                    out.push(v);
                    i += 2;
                }
                None => out.push(b'%'),
            },
            c => out.push(c),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn main() {
    let client_id = arg("--client-id").unwrap_or_else(|| "gunnar-console".to_owned());
    let Some(client_secret) = arg("--client-secret") else {
        eprintln!("fake_idp: --client-secret is required (the caller's, never printed)");
        std::process::exit(2);
    };
    // `--https` (the estate-box gate, 2026-10-12: the console sends a person only to an https
    // IdP): the OpenID provider over https under a throwaway CA it prints as `oidc_ca_pem` —
    // the CA the account admin hands gunnar (`idp_ca_pem`) — bound on `--oidc-bind <ip>`
    // (default 127.0.0.1; a bench address the estate admits as an internal IdP network).
    let https = std::env::args().any(|a| a == "--https");
    let op = if https {
        let at: std::net::IpAddr = arg("--oidc-bind")
            .unwrap_or_else(|| "127.0.0.1".to_owned())
            .parse()
            .expect("--oidc-bind <an IP address>");
        FakeOp::start_https_on(at, &client_id, &client_secret)
            .expect("the fake OpenID provider binds")
    } else {
        FakeOp::start(&client_id, &client_secret).expect("the fake OpenID provider binds")
    };
    let idp = FakeIdp::start().expect("the fake SAML IdP binds");
    let control = TcpListener::bind("127.0.0.1:0").expect("the control door binds");
    let line = serde_json::json!({
        "oidc_issuer": op.issuer(),
        "oidc_ca_pem": op.ca_pem(),
        "client_id": client_id,
        "saml_metadata_url": idp.metadata_url(),
        "saml_entity_id": idp.entity_id(),
        "control": format!("http://{}", control.local_addr().expect("an address")),
    });
    println!("{line}");
    let _ = std::io::stdout().flush();
    for stream in control.incoming() {
        let Ok(mut stream) = stream else { continue };
        let mut reader = BufReader::new(stream.try_clone().expect("a clone"));
        let mut request = String::new();
        if reader.read_line(&mut request).is_err() {
            continue;
        }
        loop {
            let mut h = String::new();
            if reader.read_line(&mut h).map_or(true, |n| n == 0) || h == "\r\n" || h == "\n" {
                break;
            }
        }
        let target = request
            .split_whitespace()
            .nth(1)
            .unwrap_or_default()
            .to_owned();
        let (path, query) = target.split_once('?').unwrap_or((&target, ""));
        let q: std::collections::HashMap<String, String> = query
            .split('&')
            .filter_map(|p| p.split_once('='))
            .map(|(k, v)| (decode(k), decode(v)))
            .collect();
        let get = |k: &str| q.get(k).cloned().unwrap_or_default();
        let said = match path {
            "/person" => {
                let (sub, email, name) = (get("sub"), get("email"), get("name"));
                op.set_person(fake_op::Person::new(&sub, &email, &name));
                idp.set_person(fake_idp::Person::new(&email, &email, &name));
                format!("person {email}")
            }
            "/tweak" => {
                let oidc = match get("oidc").as_str() {
                    "WrongAudience" => fake_op::Tweak::WrongAudience,
                    "Expired" => fake_op::Tweak::Expired,
                    "WrongNonce" => fake_op::Tweak::WrongNonce,
                    "ForeignKey" => fake_op::Tweak::ForeignKey,
                    "WrongIssuer" => fake_op::Tweak::WrongIssuer,
                    "HmacConfusion" => fake_op::Tweak::HmacConfusion,
                    _ => fake_op::Tweak::None,
                };
                op.tweak_next(oidc);
                let saml = match get("saml").as_str() {
                    "WrongAudience" => fake_idp::Tweak::WrongAudience,
                    "Expired" => fake_idp::Tweak::Expired,
                    "ForeignKey" => fake_idp::Tweak::ForeignKey,
                    "Unsigned" => fake_idp::Tweak::Unsigned,
                    "WrongIssuer" => fake_idp::Tweak::WrongIssuer,
                    _ => fake_idp::Tweak::None,
                };
                idp.tweak_next(saml);
                format!("tweak {oidc:?} {saml:?}")
            }
            "/tokens" => format!("{}", op.tokens_issued()),
            _ => "unknown".to_owned(),
        };
        let _ = write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{said}", said.len());
    }
}