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());
}
}