crates/gunnar-server/tests/sso_keycloak.rs
//! ★ **Company SSO against a REAL identity provider** (owner 2026-10-26, lane T-OWNERFLOW:
//! "add tests for this, maybe container-based IdM"). `tests/sso.rs` holds every rule against
//! edda's in-process fakes; this file holds what only a real IdP can show: Keycloak's own
//! discovery document, key set, ID tokens, login form, SAML metadata, signatures and
//! assertions, reached through gunnar's REAL egress (`UreqEgress`, the pinned resolver, an
//! https IdP on the operator's internal network behind the account's own CA).
//!
//! The IdP is Keycloak in a container (`KEYCLOAK_IMAGE` in `xtask/src/idp.rs`), started by
//! **`cargo xtask idp`**: a private podman network, Keycloak on it as `idp.acme.test:8443`
//! over https with a certificate from a CA minted for the run, and this test binary in the
//! toolchain container on the same network. Every test here is `#[ignore]`d so a plain
//! `cargo test` (no IdP) skips it by name; the xtask runs them with `--ignored`.
//!
//! The environment the xtask hands in (all required; a test without it fails by name):
//! * `GUNNAR_IDP_URL` — `https://idp.acme.test:8443`;
//! * `GUNNAR_IDP_CA` — the run CA's PEM (a FILE path);
//! * `GUNNAR_IDP_ADMIN_PASSWORD_FILE` — Keycloak's bootstrap admin password (a FILE path,
//! never printed);
//! * `GUNNAR_IDP_NETS` — the podman network's subnet (the operator's internal network).
//!
//! Each test makes its OWN realm through Keycloak's admin REST API, so the tests share the
//! container and nothing else. People are `*@acme.mock.invalid`.
//!
//! Tests that are RED on today's code for a row another lane fixes carry their own ignore
//! reason (`red until <row> merges`), and the xtask skips them by name until the row merges
//! (`xtask/src/idp.rs` `RED_UNTIL`).
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client; the appliance rows and CI name `sso-egress`.
use std::collections::BTreeMap;
use std::io::Read as _;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gunnar_grpc::auth::{AuditLedger, AuditRecord, AuthGate, SESSION_METADATA_KEY};
use gunnar_grpc::sso::PrivateNets;
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 p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};
const ORIGIN: &str = "https://gunnar.acme.test";
const RP_ID: &str = "gunnar.acme.test";
const SECRET: &str = "keycloak-client-secret-NEVER-LOGGED";
const DOMAIN: &str = "acme.mock.invalid";
const PASSWORD: &str = "a-test-password-for-a-test-realm";
// ── the IdP, from the xtask's environment ───────────────────────────────────
#[derive(Clone)]
struct Idp {
base: String,
ca_pem: String,
admin_password: String,
nets: String,
}
fn env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
panic!("[idp-env] {name} is not set: these tests need a real Keycloak, run them with `cargo xtask idp`")
})
}
impl Idp {
fn from_env() -> Idp {
let read = |var: &str| {
let path = env(var);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("[idp-env] {var}={path}: {e}"))
};
Idp {
base: env("GUNNAR_IDP_URL").trim_end_matches('/').to_owned(),
ca_pem: read("GUNNAR_IDP_CA"),
admin_password: read("GUNNAR_IDP_ADMIN_PASSWORD_FILE").trim().to_owned(),
nets: env("GUNNAR_IDP_NETS"),
}
}
fn issuer(&self, realm: &str) -> String {
format!("{}/realms/{realm}", self.base)
}
/// A client that trusts the run's CA, follows NO redirect and returns every status.
fn agent(&self) -> ureq::Agent {
let ca =
ureq::tls::Certificate::from_pem(self.ca_pem.as_bytes()).expect("the run CA parses");
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::Specific(Arc::new(vec![ca])))
.build();
ureq::Agent::config_builder()
.tls_config(tls)
.max_redirects(0)
.max_redirects_will_error(false)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into()
}
fn admin_token(&self) -> String {
let mut r = self
.agent()
.post(&format!(
"{}/realms/master/protocol/openid-connect/token",
self.base
))
.send_form([
("grant_type", "password"),
("client_id", "admin-cli"),
("username", "admin"),
("password", self.admin_password.as_str()),
])
.expect("Keycloak's token door answers");
let body = r.body_mut().read_to_string().unwrap_or_default();
assert_eq!(
r.status().as_u16(),
200,
"the Keycloak admin sign-in was refused (body withheld)"
);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
v["access_token"].as_str().unwrap().to_owned()
}
/// One admin REST call: (status, body, Location).
fn admin(
&self,
method: &str,
path: &str,
body: Option<serde_json::Value>,
) -> (u16, String, String) {
let url = format!("{}/admin/realms{path}", self.base);
let auth = format!("Bearer {}", self.admin_token());
let agent = self.agent();
let json = body.unwrap_or(serde_json::Value::Null).to_string();
let resp = match method {
"GET" => agent.get(&url).header("Authorization", &auth).call(),
"DELETE" => agent.delete(&url).header("Authorization", &auth).call(),
"POST" => agent
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.send(json),
"PUT" => agent
.put(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.send(json),
m => panic!("{m}"),
};
let mut resp = resp.unwrap_or_else(|e| panic!("{method} {path}: {e}"));
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let status = resp.status().as_u16();
(
status,
resp.body_mut().read_to_string().unwrap_or_default(),
location,
)
}
fn admin_ok(
&self,
method: &str,
path: &str,
body: Option<serde_json::Value>,
) -> (String, String) {
let (status, text, location) = self.admin(method, path, body);
assert!(
(200..300).contains(&status),
"Keycloak admin {method} {path}: {status} {text}"
);
(text, location)
}
/// A fresh realm (a leftover of an earlier run is deleted first).
fn realm(&self, name: &str) {
let _ = self.admin("DELETE", &format!("/{name}"), None);
self.admin_ok(
"POST",
"",
Some(serde_json::json!({"realm": name, "enabled": true, "sslRequired": "external"})),
);
}
/// A confidential OIDC client for gunnar's callback.
fn oidc_client(&self, realm: &str, client_id: &str) {
self.admin_ok(
"POST",
&format!("/{realm}/clients"),
Some(serde_json::json!({
"clientId": client_id, "secret": SECRET, "publicClient": false, "protocol": "openid-connect",
"standardFlowEnabled": true, "directAccessGrantsEnabled": true,
"redirectUris": [format!("{ORIGIN}/auth/sso/oidc/callback")],
})),
);
}
/// A person in the realm, with a password; `verified` is Keycloak's emailVerified. Returns
/// Keycloak's id for them (the OIDC `sub`).
fn user(&self, realm: &str, local: &str, first: &str, verified: bool) -> String {
let (_, location) = self.admin_ok(
"POST",
&format!("/{realm}/users"),
Some(serde_json::json!({
"username": local, "email": format!("{local}@{DOMAIN}"), "firstName": first, "lastName": "Example",
"enabled": true, "emailVerified": verified,
"credentials": [{"type": "password", "value": PASSWORD, "temporary": false}],
})),
);
location.rsplit('/').next().unwrap_or_default().to_owned()
}
fn client_uuid(&self, realm: &str, client_id: &str) -> String {
let (text, _) = self.admin_ok(
"GET",
&format!("/{realm}/clients?clientId={}", pct(client_id)),
None,
);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
v[0]["id"]
.as_str()
.unwrap_or_else(|| panic!("no client {client_id}: {text}"))
.to_owned()
}
/// A SAML client: `entity_id` (what the AuthnRequest's Issuer names) answering at gunnar's
/// ACS, NameID = the e-mail, documents and assertions signed, requests unsigned.
fn saml_client(&self, realm: &str, entity_id: &str) {
self.admin_ok(
"POST",
&format!("/{realm}/clients"),
Some(serde_json::json!({
"clientId": entity_id, "protocol": "saml", "enabled": true, "frontchannelLogout": false,
"redirectUris": [format!("{ORIGIN}/auth/sso/saml/acs")],
"attributes": {
"saml.client.signature": "false",
"saml.server.signature": "true",
"saml.assertion.signature": "true",
"saml.signature.algorithm": "RSA_SHA256",
"saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#",
"saml_name_id_format": "email",
"saml_force_name_id_format": "true",
"saml_assertion_consumer_url_post": format!("{ORIGIN}/auth/sso/saml/acs"),
"saml.authnstatement": "true",
},
})),
);
}
fn saml_metadata(&self, realm: &str) -> String {
let mut r = self
.agent()
.get(&format!(
"{}/realms/{realm}/protocol/saml/descriptor",
self.base
))
.call()
.expect("the SAML descriptor");
assert_eq!(r.status().as_u16(), 200);
r.body_mut().read_to_string().unwrap()
}
/// **Keycloak's own login form, driven as a browser drives it**, in a FRESH cookie jar:
/// GET the start URL, POST the form `#kc-form-login` with the person's password. The answer:
/// a redirect's Location (OIDC: back to gunnar with the code), or the page Keycloak drew
/// (SAML: the auto-post form carrying the response).
fn sign_in(&self, start: &str, user: &str) -> Answer {
let agent = self.agent();
let mut jar: BTreeMap<String, String> = BTreeMap::new();
let mut first = agent
.get(start)
.call()
.unwrap_or_else(|e| panic!("GET {start}: {e}"));
keep_cookies(&mut jar, &first);
let status = first.status().as_u16();
let html = first.body_mut().read_to_string().unwrap_or_default();
assert_eq!(
status,
200,
"Keycloak did not draw its login form: {status} {}",
cut(&html, 400)
);
let action = form_action(&html, "kc-form-login")
.unwrap_or_else(|| panic!("no #kc-form-login: {}", cut(&html, 600)));
let mut done = agent
.post(&action)
.header("Cookie", &cookie_header(&jar))
.send_form([
("username", user),
("password", PASSWORD),
("credentialId", ""),
])
.unwrap_or_else(|e| panic!("POST the login form: {e}"));
let status = done.status().as_u16();
let location = done
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let page = done.body_mut().read_to_string().unwrap_or_default();
if (300..400).contains(&status) {
Answer::Redirect(location)
} else {
Answer::Page(status, page)
}
}
}
enum Answer {
Redirect(String),
Page(u16, String),
}
fn keep_cookies(jar: &mut BTreeMap<String, String>, resp: &ureq::http::Response<ureq::Body>) {
for v in resp.headers().get_all("set-cookie") {
let pair = v
.to_str()
.unwrap_or_default()
.split(';')
.next()
.unwrap_or_default();
if let Some((n, val)) = pair.split_once('=') {
jar.insert(n.trim().to_owned(), val.trim().to_owned());
}
}
}
fn cookie_header(jar: &BTreeMap<String, String>) -> String {
jar.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ")
}
fn unescape(s: &str) -> String {
s.replace("&", "&")
.replace(""", "\"")
.replace("/", "/")
.replace("=", "=")
.replace("+", "+")
.replace("+", "+")
}
/// The `action` of the form whose id is `id`.
fn form_action(html: &str, id: &str) -> Option<String> {
let at = html.find(&format!("id=\"{id}\""))?;
let start = html[..at].rfind("<form")?;
let tag = &html[start..start + html[start..].find('>')?];
let a = tag.find("action=\"")? + 8;
Some(unescape(&tag[a..a + tag[a..].find('"')?]))
}
/// The value of the hidden input named `name` (Keycloak's SAML auto-post page).
fn hidden(html: &str, name: &str) -> Option<String> {
let at = html.find(&format!("name=\"{name}\""))?;
let rest = &html[at..];
let v = rest.find("value=\"")? + 7;
Some(unescape(&rest[v..v + rest[v..].find('"')?]))
}
fn cut(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
fn pct(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
fn unpct(v: &str) -> String {
let b = v.as_bytes();
let mut out = Vec::new();
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()
}
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)| unpct(v))
.unwrap_or_default()
}
// ── base64 (standard), for the SAML redirect binding ─────────────────────────
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn b64(bytes: &[u8]) -> String {
let mut out = String::new();
for c in bytes.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= c.len() {
out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn unb64(s: &str) -> Vec<u8> {
let mut out = Vec::new();
let (mut acc, mut bits) = (0u32, 0);
for ch in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
let v = B64.iter().position(|b| *b == ch).expect("base64") as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
out
}
/// **The same AuthnRequest, naming ANOTHER service provider as its issuer** (the same request
/// ID, so the answer is InResponseTo this flow): Keycloak answers it for that SP, so the
/// assertion's audience is not gunnar's.
fn reissued(redirect_url: &str, other_entity: &str, own_entity: &str) -> String {
use std::io::Write as _;
let raw = unb64(&query(redirect_url, "SAMLRequest"));
let mut xml = String::new();
flate2::read::DeflateDecoder::new(&raw[..])
.read_to_string(&mut xml)
.expect("a deflated AuthnRequest");
assert!(
xml.contains(own_entity),
"gunnar's AuthnRequest names its own entity id: {xml}"
);
let xml = xml.replace(own_entity, other_entity);
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(xml.as_bytes()).unwrap();
let base = redirect_url.split_once('?').unwrap().0;
format!(
"{base}?SAMLRequest={}&RelayState={}",
pct(&b64(&z.finish().unwrap())),
pct(&query(redirect_url, "RelayState"))
)
}
// ── the harness: the real plane and stores, gunnar's real egress ────────────
#[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
));
}
}
struct Harness {
_dir: tempfile::TempDir,
plane: Arc<CoreControlPlane>,
identity: Arc<gunnar_grpc::identity::GunnarIdentity>,
stores: Arc<ServerStores>,
ctx: ServiceContext,
ledger: Arc<Ledger>,
console: Caller,
idp: Idp,
realm: String,
}
fn person(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: String::new(),
machine: false,
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
}
fn now_ms() -> i64 {
gunnar_core::now_ms()
}
/// The browser's relying party, as the console relays it to the core.
fn relay() -> nornir_identity::Relay {
nornir_identity::Relay {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: None,
}
}
impl Harness {
/// `realm`: this test's own realm at the IdP. The box's egress is gunnar's own; the
/// operator lists the IdP's network (`GUNNAR_IDP_NETS`) unless `with_nets` is false.
fn new(realm: &str, with_nets: bool) -> Harness {
let idp = Idp::from_env();
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(Arc::clone(&identity));
if with_nets {
ctx.sso.set_private_nets(
PrivateNets::parse(&idp.nets).expect("the IdP's network is a private one"),
);
}
ctx.sso
.set_egress(Arc::new(gunnar_server::sso_egress::UreqEgress::with_nets(
false,
gunnar_server::sso_egress::system_lookup(),
ctx.sso.private_nets_handle(),
)));
// The operator's switch (A34-16): this rig dials its IdP.
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");
}
idp.realm(realm);
Harness {
_dir: dir,
plane,
identity,
stores,
ctx,
ledger,
console: Caller {
principal_id: "gunnar-ui".into(),
fingerprint: String::new(),
machine: true,
authenticated: true,
},
idp,
realm: realm.to_owned(),
}
}
fn sso(&self) -> SsoService {
SsoService::new(self.ctx.clone())
}
fn client_id(&self) -> String {
format!("gunnar-{}", self.realm)
}
/// acme's OIDC provider at this realm: the user-name claim is the e-mail, the domain is
/// acme's, the IdP CA is the run's (or none).
fn oidc_provider(
&self,
require_sso: bool,
require_passkey: bool,
with_ca: bool,
) -> SsoProvider {
SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Oidc as i32,
display_name: "Acme Keycloak".into(),
domains: vec![DOMAIN.into()],
oidc: Some(OidcSettings {
issuer: self.idp.issuer(&self.realm),
client_id: self.client_id(),
username_claim: "email".into(),
idp_ca_pem: if with_ca {
self.idp.ca_pem.clone()
} else {
String::new()
},
..Default::default()
}),
require_sso,
require_passkey,
console_origin: ORIGIN.into(),
..Default::default()
}
}
async fn set(&self, provider: SsoProvider) -> Result<ProviderView, tonic::Status> {
let admin = person(&provider.account);
self.sso()
.set_provider(authed(
&admin,
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 begin(&self) -> BeginResponse {
self.sso()
.begin(authed(
&self.console,
BeginRequest {
account: "acme".into(),
login_hint: String::new(),
console_origin: ORIGIN.into(),
client: String::new(),
},
))
.await
.expect("begun")
.into_inner()
}
/// One whole OIDC sign-in of `local` through Keycloak's login form: (Finish, session key).
async fn oidc_login(&self, local: &str) -> (Result<FinishResponse, tonic::Status>, String) {
let begun = self.begin().await;
assert!(
begun
.redirect_url
.starts_with(&self.idp.issuer(&self.realm)),
"{}",
begun.redirect_url
);
let back = match self.idp.sign_in(&begun.redirect_url, local) {
Answer::Redirect(l) => l,
Answer::Page(s, p) => {
panic!("Keycloak did not send {local} back: {s} {}", cut(&p, 600))
}
};
assert!(
back.starts_with(&format!("{ORIGIN}/auth/sso/oidc/callback")),
"{back}"
);
assert_eq!(query(&back, "state"), begun.state, "the state comes back");
let finished = self
.sso()
.finish(authed(
&self.console,
FinishRequest {
state: begun.state.clone(),
code: query(&back, "code"),
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.map(|r| r.into_inner());
let session = finished
.as_ref()
.map(|f| f.session.clone())
.unwrap_or_default();
(finished, session)
}
async fn finish_saml(
&self,
state: &str,
response: &str,
) -> Result<FinishResponse, tonic::Status> {
self.sso()
.finish(authed(
&self.console,
FinishRequest {
state: state.into(),
saml_response: response.into(),
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.map(|r| r.into_inner())
}
/// Through Keycloak's SAML login: (RelayState, SAMLResponse) from its auto-post page.
fn saml_post(&self, redirect_url: &str, local: &str) -> (String, String) {
match self.idp.sign_in(redirect_url, local) {
Answer::Page(200, page) => (
hidden(&page, "RelayState").unwrap_or_default(),
hidden(&page, "SAMLResponse").unwrap_or_else(|| {
panic!("no SAMLResponse on Keycloak's page: {}", cut(&page, 600))
}),
),
Answer::Page(s, page) => panic!(
"Keycloak answered the SAML sign-in {s}: {}",
cut(&page, 600)
),
Answer::Redirect(l) => panic!("a SAML sign-in redirected to {l}"),
}
}
fn employees(&self) -> Vec<String> {
self.plane
.employees("acme")
.expect("employees")
.into_iter()
.map(|p| p.id)
.collect()
}
async fn directory(&self) -> Vec<DirectoryUser> {
self.sso()
.list_directory(authed(
&person("acme"),
ListDirectoryRequest {
account: "acme".into(),
},
))
.await
.expect("acme's directory")
.into_inner()
.users
}
fn audit(&self) -> String {
self.ledger.0.lock().unwrap().join("\n")
}
fn account_log(&self) -> Vec<String> {
let log = self
.stores
.account_audit()
.query(
"acme",
&gunnar_core::account_audit::AuditQuery {
limit: 200,
..Default::default()
},
)
.expect("acme's log");
log.entries
.iter()
.map(|e| format!("{}:{}:{}:{}", e.kind, e.actor, e.target, e.detail))
.collect()
}
async fn scim_token(&self) -> (String, String) {
let made = self
.sso()
.create_scim_token(authed(
&person("acme"),
CreateScimTokenRequest {
account: "acme".into(),
label: "Keycloak test".into(),
..Default::default()
},
))
.await
.expect("a New SCIM token")
.into_inner();
(made.secret, made.token.expect("its row").token_id)
}
/// One SCIM call as a directory client sends it: (status, JSON, the sessions it ended).
async fn scim_full(
&self,
bearer: &str,
method: &str,
path: &str,
body: serde_json::Value,
) -> (u32, serde_json::Value, Vec<String>) {
let answer = self
.sso()
.scim(authed(
&self.console,
ScimRequest {
bearer: bearer.into(),
method: method.into(),
path: path.into(),
query: String::new(),
body: if body.is_null() {
Vec::new()
} else {
serde_json::to_vec(&body).unwrap()
},
base_url: format!("{ORIGIN}/scim/v2"),
..Default::default()
},
))
.await
.expect("relayed")
.into_inner();
let json = if answer.body.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&answer.body).unwrap()
};
(answer.status, json, answer.ended_sessions)
}
async fn scim(
&self,
bearer: &str,
method: &str,
path: &str,
body: serde_json::Value,
) -> (u32, serde_json::Value) {
let (s, j, _) = self.scim_full(bearer, method, path, body).await;
(s, j)
}
/// `Sso.ImportUsers`, then `Sso.ImportStatus` until the job ended (A34-17).
async fn import(&self, csv: &str, dry_run: bool) -> ImportUsersResponse {
let mut job = self
.sso()
.import_users(authed(
&person("acme"),
ImportUsersRequest {
account: "acme".into(),
csv: csv.into(),
dry_run,
},
))
.await
.expect("the CSV import answers")
.into_inner();
for _ in 0..500 {
if job.state == "done" || job.state == "failed" {
return job;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
job = self
.sso()
.import_status(authed(
&person("acme"),
ImportStatusRequest {
account: "acme".into(),
job_id: job.job_id.clone(),
},
))
.await
.expect("the import job answers")
.into_inner();
}
panic!("the import job never ended: {job:?}");
}
}
// ── passkeys (the same software authenticator as tests/sso.rs) ──────────────
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 client_data(kind: &str, challenge: &[u8]) -> Vec<u8> {
format!(
r#"{{"type":"{kind}","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
gunnar_grpc::webauthn::b64url(challenge)
)
.into_bytes()
}
fn assert(&self, challenge: &[u8], counter: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let cd = Self::client_data("webauthn.get", challenge);
let mut ad = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
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())
}
}
impl Harness {
/// The identity core's sessions on this box.
fn sessions(&self) -> usize {
self.identity.host().gunnar_store().tables().sessions.len()
}
/// The caller the real gate makes of a core session, or its refusal.
fn gate_session(&self, token: &str) -> Result<Caller, tonic::Status> {
let mut request = Request::new(());
request
.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
.with_identity(Some(Arc::clone(&self.identity)))
.authenticate_request(&request, now_ms())
.map_err(|failure| failure.status())
}
/// A passkey login through the identity core, as the console relays it.
fn core_login(
&self,
by: &Authenticator,
counter: u32,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.login_start(&relay())?;
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
let (cd, ad, sig) = by.assert(&challenge, counter);
self.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(),
})
}
/// **A passkey login, and what it may then do**: the core's login, then the
/// session at the real gate — the principal it acts as, or the gate's
/// refusal (`require_sso`: a passkey alone is no credential).
fn passkey_login(&self, by: &Authenticator, counter: u32) -> Result<String, tonic::Status> {
let login = self
.core_login(by, counter)
.map_err(nornir_identity::grpc::status)?;
self.gate_session(&login.token).map(|c| c.principal_id)
}
/// Redeem the core's first-passkey `link` with `by`, as the console relays
/// it: the session the core minted.
fn redeem(
&self,
link: &str,
by: &Authenticator,
attestation_object: Vec<u8>,
public_key: Vec<u8>,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.redeem_start(link, "", &relay())?;
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
self.identity.redeem_finish(nornir_identity::Registration {
ceremony_id: c.id,
credential_id: by.id.clone(),
public_key,
client_data_json: Authenticator::client_data("webauthn.create", &challenge),
attestation_object,
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
})
}
async fn second_factor(
&self,
pending: &str,
session: &str,
) -> Result<SecondFactorResponse, tonic::Status> {
self.sso()
.second_factor(authed(
&self.console,
SecondFactorRequest {
pending: pending.into(),
session: session.into(),
},
))
.await
.map(|r| r.into_inner())
}
}
// ── OIDC ───────────────────────────────────────────────────────────────────
/// ★ **OIDC through Keycloak, behind the account's own CA, on the operator's network.** The
/// account admin's Identity provider settings (issuer, client id and secret, the user-name
/// claim, the e-mail domain, the IdP CA PEM): without the CA the issuer is refused by name
/// (gunnar reads Keycloak's discovery at once), without the operator's network listed it is
/// refused by name too; with both, Keycloak's own login form signs bob in and he is a NEW
/// employee of acme, the session key his; the second sign-in is the same person. The client
/// secret is in no answer and no audit record; the change and both sign-ins are in acme's log.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn keycloak_oidc_signs_in_a_new_employee_behind_the_accounts_idp_ca() {
let h = Harness::new("oidc", true);
h.idp.oidc_client("oidc", &h.client_id());
h.idp.user("oidc", "bob", "Bob", true);
let no_ca = h
.set(h.oidc_provider(false, false, false))
.await
.expect_err("an issuer behind a CA nobody trusts");
assert_eq!(no_ca.code(), Code::FailedPrecondition, "{no_ca:?}");
assert!(
no_ca.message().contains("the issuer could not be read")
&& no_ca.message().to_lowercase().contains("certificate"),
"refused by name: {}",
no_ca.message()
);
let elsewhere = Harness::new("oidc-nets", false);
elsewhere
.idp
.oidc_client("oidc-nets", &elsewhere.client_id());
let no_net = elsewhere
.set(elsewhere.oidc_provider(false, false, true))
.await
.expect_err("an IdP on a network the operator did not list");
assert_eq!(no_net.code(), Code::FailedPrecondition, "{no_net:?}");
assert!(
no_net.message().contains("the issuer could not be read")
&& no_net.message().contains("private"),
"refused by name: {}",
no_net.message()
);
let view = h
.set(h.oidc_provider(false, false, true))
.await
.expect("the account admin configures Keycloak");
let shown = view.provider.clone().expect("provider");
assert!(shown.has_client_secret);
// A typed domain routes nothing until proven (A34-5): the rig proves it in the
// table (tests/sso.rs reads a DNS proof).
h.stores
.auth()
.verify_sso_domain("acme", DOMAIN)
.expect("the rig proves acme's domain");
let oidc = shown.oidc.clone().unwrap();
assert!(
oidc.idp_ca_pem.is_empty() && oidc.idp_ca_fingerprints.len() == 1,
"the CA is shown by fingerprint only: {oidc:?}"
);
assert_eq!(
view.redirect_uri,
format!("{ORIGIN}/auth/sso/oidc/callback")
);
assert!(
!format!("{view:?}").contains(SECRET),
"the client secret came back"
);
let found = h
.sso()
.discover(authed(
&h.console,
DiscoverRequest {
hint: format!("Bob@{}", DOMAIN.to_uppercase()),
},
))
.await
.unwrap()
.into_inner();
assert!(
found.found && found.account == "acme" && found.display_name == "Acme Keycloak",
"{found:?}"
);
let (first, key) = h.oidc_login("bob").await;
let first = first.expect("Keycloak's ID token verified by gunnar");
assert!(first.created, "{first:?}");
let bob = first.principal.as_ref().unwrap().id.clone();
assert_eq!(bob, "acme.bob");
assert_eq!(
h.stores.auth().employer_of(&bob).unwrap().as_deref(),
Some("acme")
);
assert_eq!(first.session, key);
assert_eq!(
h.gate_session(&key).expect("the session acts").principal_id,
"acme.bob",
"the session is the employee's"
);
let (second, _) = h.oidc_login("bob").await;
let second = second.expect("the second sign-in");
assert!(!second.created);
assert_eq!(second.principal.unwrap().id, "acme.bob");
assert_eq!(h.employees(), vec!["acme.bob".to_owned()]);
let bob_row = h
.directory()
.await
.into_iter()
.find(|u| u.principal_id == "acme.bob")
.expect("bob in the Directory");
assert!(
bob_row.bound && bob_row.active && bob_row.source == "jit",
"{bob_row:?}"
);
assert_eq!(bob_row.email, format!("bob@{DOMAIN}"));
let trail = h.audit();
assert!(
trail.contains("Sso.SetProvider principal=acme scope=account:acme allow"),
"{trail}"
);
assert!(
trail.contains("Sso.Finish principal=acme.bob scope=account:acme allow")
&& trail.contains("created=true"),
"{trail}"
);
assert!(
!trail.contains(SECRET),
"the client secret reached the audit trail"
);
let log = h.account_log();
assert!(
log.iter().any(|e| e.starts_with("sso.provider_set:acme:")),
"{log:#?}"
);
assert_eq!(
log.iter()
.filter(|e| e.starts_with("login:acme.bob:sso"))
.count(),
2,
"{log:#?}"
);
assert!(log.iter().all(|e| !e.contains(SECRET)), "{log:#?}");
}
/// ★ **"Require single sign-on" and "Also require a passkey after the IdP", with Keycloak.**
/// Required: an employee's passkey alone is refused by name, the account admin's passkey
/// still signs in (the break-glass). Then a passkey after the IdP: Keycloak's sign-in gives no
/// session until the first passkey is registered; the next sign-in asks for that passkey.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn keycloak_require_sso_keeps_the_admins_passkey_and_puts_a_passkey_after_the_idp() {
let h = Harness::new("required", true);
h.idp.oidc_client("required", &h.client_id());
h.idp.user("required", "cleo", "Cleo", true);
h.set(h.oidc_provider(true, false, true))
.await
.expect("require SSO");
let (answer, _) = h.oidc_login("cleo").await;
let cleo = answer.expect("cleo through Keycloak").principal.unwrap().id;
let cleos = Authenticator::new(3, b"cleo-key");
h.plane
.add_credential(&cleo, &cleos.id, &cleos.public(), "cleo's key", 0)
.unwrap();
let admins = Authenticator::new(4, b"acme-admin-key");
h.plane
.add_credential("acme", &admins.id, &admins.public(), "acme's key", 0)
.unwrap();
let refused = h
.passkey_login(&cleos, 1)
.expect_err("an employee's passkey alone");
assert_eq!(refused.code(), Code::PermissionDenied);
assert!(
refused.message().contains("Sign in with Acme Keycloak"),
"{}",
refused.message()
);
assert_eq!(
h.passkey_login(&admins, 1)
.expect("the admin keeps the passkey"),
"acme"
);
h.set(h.oidc_provider(true, true, true))
.await
.expect("a passkey after the IdP");
h.idp.user("required", "dora", "Dora", true);
// GHUNT-8: the core keeps every session in one table since IDENTITY-HOST (cleo's and
// the admin's sign-ins above are in it), so what is asserted is that dora's Keycloak
// sign-in adds none before her first passkey.
let before = h.sessions();
let (first, key) = h.oidc_login("dora").await;
let first = first.expect("dora through Keycloak");
assert_eq!(
first.second_factor,
SecondFactorNeed::PasskeyRegistration as i32,
"{first:?}"
);
assert!(key.is_empty(), "a session before the passkey");
assert_eq!(h.sessions(), before, "a session before the passkey");
let doras = Authenticator::new(5, b"dora-first-key");
let made = h
.redeem(
&first.enrolment,
&doras,
gunnar_grpc::webauthn::attestation_none(RP_ID, &doras.id, &doras.public(), 0),
doras.public(),
)
.expect("the first passkey registers through the core");
h.second_factor(&first.pending, &made.token)
.await
.expect("the first passkey signs in");
assert_eq!(
h.gate_session(&made.token).expect("admitted").principal_id,
made.session.user_id
);
let (next, _) = h.oidc_login("dora").await;
let next = next.expect("dora again");
assert_eq!(next.second_factor, SecondFactorNeed::Passkey as i32);
assert_eq!(next.credential_ids, vec![doras.id.clone()]);
let again = h.core_login(&doras, 1).expect("dora's passkey");
h.second_factor(&next.pending, &again.token)
.await
.expect("dora's passkey after Keycloak");
let trail = h.audit();
assert!(
trail
.matches("Sso.SetProvider principal=acme scope=account:acme allow")
.count()
>= 2,
"{trail}"
);
}
// ── SAML ───────────────────────────────────────────────────────────────────
impl Harness {
fn sp_entity(&self) -> String {
format!("{ORIGIN}/auth/sso/saml/acme")
}
/// acme's SAML provider from Keycloak's metadata (pasted: see the A34-41 test for the URL).
async fn saml_configure(&self) -> ProviderView {
let metadata = self.idp.saml_metadata(&self.realm);
let view = self
.set(SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme Keycloak SAML".into(),
domains: vec![DOMAIN.into()],
saml: Some(SamlSettings {
idp_metadata_xml: metadata,
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
})
.await
.expect("Keycloak's SAML metadata is taken");
let saml = view.provider.clone().unwrap().saml.unwrap();
assert_eq!(saml.idp_entity_id, self.idp.issuer(&self.realm), "{saml:?}");
assert!(
saml.idp_sso_url.starts_with(&self.idp.issuer(&self.realm)),
"{saml:?}"
);
assert!(
!saml.idp_certificates.is_empty()
&& saml
.idp_certificates
.iter()
.all(|c| c.starts_with("SHA256:")),
"{saml:?}"
);
assert_eq!(view.acs_url, format!("{ORIGIN}/auth/sso/saml/acs"));
view
}
}
/// ★ **SAML through Keycloak**: gunnar's AuthnRequest, Keycloak's login form, its signed
/// response posted back: a NEW employee. Then each refusal by name: the same response replayed;
/// an assertion for ANOTHER service provider (the same request reissued under another SP's
/// entity id, so only the audience differs); an assertion past its NotOnOrAfter (a one-second
/// lifespan on Keycloak's client, posted after gunnar's skew).
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn keycloak_saml_signs_in_and_refuses_a_replay_another_audience_and_an_expired_assertion() {
let h = Harness::new("saml", true);
let other = "https://other-sp.acme.test/saml";
h.idp.saml_client("saml", &h.sp_entity());
h.idp.saml_client("saml", other);
h.idp.user("saml", "carol", "Carol", true);
h.saml_configure().await;
let begun = h.begin().await;
assert_eq!(begun.protocol, SsoProtocol::Saml as i32);
let (relay, response) = h.saml_post(&begun.redirect_url, "carol");
assert_eq!(relay, begun.state, "the RelayState comes back");
let ok = h
.finish_saml(&relay, &response)
.await
.expect("Keycloak's signed response");
assert!(ok.created, "{ok:?}");
assert_eq!(ok.principal.unwrap().id, "acme.carol");
let again = h.begin().await;
let replay = h
.finish_saml(&again.state, &response)
.await
.expect_err("a replayed response");
assert_eq!(replay.code(), Code::PermissionDenied, "{replay:?}");
let begun = h.begin().await;
let (relay, response) = h.saml_post(
&reissued(&begun.redirect_url, other, &h.sp_entity()),
"carol",
);
let wrong = h
.finish_saml(&relay, &response)
.await
.expect_err("an assertion for another SP");
assert_eq!(wrong.code(), Code::PermissionDenied, "{wrong:?}");
assert!(
wrong.message().contains("udience"),
"refused by name: {}",
wrong.message()
);
// A one-second assertion, posted after the skew gunnar allows.
let uuid = h.idp.client_uuid("saml", &h.sp_entity());
let (text, _) = h
.idp
.admin_ok("GET", &format!("/saml/clients/{uuid}"), None);
let mut client: serde_json::Value = serde_json::from_str(&text).unwrap();
client["attributes"]["saml.assertion.lifespan"] = serde_json::json!("1");
h.idp
.admin_ok("PUT", &format!("/saml/clients/{uuid}"), Some(client));
let begun = h.begin().await;
let (relay, response) = h.saml_post(&begun.redirect_url, "carol");
tokio::time::sleep(Duration::from_secs(gunnar_grpc::sso::SKEW_SECS as u64 + 5)).await;
let expired = h
.finish_saml(&relay, &response)
.await
.expect_err("an expired assertion");
assert_eq!(expired.code(), Code::PermissionDenied, "{expired:?}");
let words = expired.message().to_lowercase();
assert!(
words.contains("expired") || words.contains("notonorafter") || words.contains("no longer"),
"refused by name: {}",
expired.message()
);
assert_eq!(
h.employees(),
vec!["acme.carol".to_owned()],
"a refused response made someone"
);
let trail = h.audit();
assert!(
trail.contains("Sso.Finish principal=acme.carol scope=account:acme allow"),
"{trail}"
);
assert!(
trail
.lines()
.filter(|l| l.starts_with("Sso.Finish") && l.contains(" deny"))
.count()
>= 3,
"each refusal is audited: {trail}"
);
}
/// ★ **A34-41: the SAML metadata by its https URL, behind the account's IdP CA.** An OIDC
/// provider's calls trust the account's own CA; the SAML metadata fetch does not, so a company
/// IdP behind its own CA could only be configured by pasting the XML (it was RED: "the IdP's
/// metadata: io: invalid peer certificate: UnknownIssuer"). The account's CA is the one CA field,
/// `OidcSettings.idp_ca_pem`, for a SAML provider too.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn keycloak_saml_metadata_by_url_is_read_behind_the_accounts_idp_ca() {
let h = Harness::new("saml-url", true);
h.idp.saml_client("saml-url", &h.sp_entity());
let url = format!("{}/protocol/saml/descriptor", h.idp.issuer("saml-url"));
let view = h
.set(SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme Keycloak SAML".into(),
saml: Some(SamlSettings {
idp_metadata_url: url,
..Default::default()
}),
oidc: Some(OidcSettings {
idp_ca_pem: h.idp.ca_pem.clone(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
})
.await
.expect("the metadata URL is read trusting acme's IdP CA");
assert_eq!(
view.provider.unwrap().saml.unwrap().idp_entity_id,
h.idp.issuer("saml-url")
);
}
// ── SCIM and the CSV import, claimed by a Keycloak sign-in ──────────────────
/// ★ **SCIM 2.0, then the person signs in through Keycloak.** A New SCIM token; a directory
/// client (Entra's and Okta's shapes) creates erik and a group, updates and deactivates him;
/// the Directory shows him (`scim`, not bound); his Keycloak sign-in CLAIMS that employee (no
/// new one); deactivated, his Keycloak sign-in is refused by name and his session ended;
/// reactivated, he signs in again; the group loses him; a revoked token is refused (401).
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn scim_provisions_employees_and_groups_that_keycloak_sign_ins_claim() {
let h = Harness::new("scim", true);
h.idp.oidc_client("scim", &h.client_id());
let kc_id = h.idp.user("scim", "erik", "Erik", true);
h.set(h.oidc_provider(false, false, true))
.await
.expect("Keycloak configured");
let (bearer, token_id) = h.scim_token().await;
let email = format!("erik@{DOMAIN}");
let (status, made) = h
.scim(&bearer, "POST", "/Users", serde_json::json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": email, "externalId": kc_id, "name": {"givenName": "Erik", "familyName": "Example"},
"emails": [{"value": email, "primary": true, "type": "work"}], "active": true,
}))
.await;
assert_eq!(status, 201, "{made}");
let id = made["id"].as_str().unwrap().to_owned();
assert_eq!(id, "acme.erik");
let (status, patched) = h
.scim(
&bearer,
"PATCH",
&format!("/Users/{id}"),
serde_json::json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "displayName", "value": "Erik E."}],
}),
)
.await;
assert_eq!(status, 200, "{patched}");
let (status, group) = h.scim(&bearer, "POST", "/Groups", serde_json::json!({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Engineering", "members": [{"value": id}]})).await;
assert_eq!(status, 201, "{group}");
let gid = group["id"].as_str().unwrap().to_owned();
let row = h
.directory()
.await
.into_iter()
.find(|u| u.principal_id == id)
.expect("erik in the Directory");
assert!(
row.source == "scim" && !row.bound && row.active && row.display_name == "Erik E.",
"{row:?}"
);
let (login, _) = h.oidc_login("erik").await;
let login = login.expect("erik signs in through Keycloak");
assert!(!login.created, "a SCIM employee was made twice: {login:?}");
assert_eq!(login.principal.as_ref().unwrap().id, id);
assert!(
h.directory()
.await
.into_iter()
.find(|u| u.principal_id == id)
.unwrap()
.bound,
"the sign-in bound him"
);
let off = serde_json::json!({"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{"op": "Replace", "path": "active", "value": "False"}]});
let (status, _, ended) = h
.scim_full(&bearer, "PATCH", &format!("/Users/{id}"), off)
.await;
assert_eq!(status, 200);
assert_eq!(
ended,
vec![id.clone()],
"the deactivation names the sessions it ended"
);
assert!(
h.gate_session(&login.session).is_err(),
"the session survived the deactivation"
);
let (refused, _) = h.oidc_login("erik").await;
let refused = refused.expect_err("a deactivated person through Keycloak");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("deactivated"),
"refused by name: {}",
refused.message()
);
let (status, _) = h
.scim(
&bearer,
"PATCH",
&format!("/Users/{id}"),
serde_json::json!({"Operations": [{"op": "replace", "value": {"active": true}}]}),
)
.await;
assert_eq!(status, 200);
// RA5-31: a sign-in in the very second of the deactivation's "signed out everywhere" is
// refused as busy (whole-second floor), and the console signs in again a moment later.
let mut again = h.oidc_login("erik").await.0;
if matches!(&again, Err(s) if s.code() == Code::ResourceExhausted && s.message().contains("this very second"))
{
tokio::time::sleep(Duration::from_millis(1100)).await;
again = h.oidc_login("erik").await.0;
}
assert!(again.is_ok(), "reactivated, erik signs in: {again:?}");
let (status, _) = h.scim(&bearer, "PATCH", &format!("/Groups/{gid}"), serde_json::json!({"Operations": [{"op": "remove", "path": format!("members[value eq \"{id}\"]")}]})).await;
assert_eq!(status, 200);
let (_, g) = h
.scim(
&bearer,
"GET",
&format!("/Groups/{gid}"),
serde_json::Value::Null,
)
.await;
assert_eq!(g["members"].as_array().map(Vec::len).unwrap_or(0), 0, "{g}");
h.sso()
.revoke_scim_token(authed(
&person("acme"),
RevokeScimTokenRequest {
account: "acme".into(),
token_id,
},
))
.await
.expect("revoked");
let (status, _) = h
.scim(&bearer, "GET", "/Users", serde_json::Value::Null)
.await;
assert_eq!(status, 401, "a revoked token still works");
let trail = h.audit();
assert!(
!trail.contains(&bearer),
"the bearer reached the audit trail"
);
assert!(
trail.contains("Sso.Scim") && trail.contains("deactivated acme.erik"),
"{trail}"
);
assert!(
trail.contains("Sso.RevokeScimToken principal=acme scope=account:acme allow"),
"{trail}"
);
}
/// ★ **The CSV import, then Keycloak sign-ins.** Check (a dry run) names the bad row and
/// writes nothing; Import makes the people; fay's Keycloak sign-in claims her imported
/// employee; a second import with active=false deactivates gus, whose Keycloak sign-in is then
/// refused by name; the Directory shows the source.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn the_csv_import_provisions_employees_that_keycloak_sign_ins_claim() {
let h = Harness::new("csv", true);
h.idp.oidc_client("csv", &h.client_id());
h.idp.user("csv", "fay", "Fay", true);
h.idp.user("csv", "gus", "Gus", true);
h.set(h.oidc_provider(false, false, true))
.await
.expect("Keycloak configured");
// GHUNT-8: an e-mail claims a provisioned employee only on a PROVEN domain (A34-5,
// `sso-claim-domain`); the rig proves acme's after the provider is set, as the
// domain-routing test does.
h.stores
.auth()
.verify_sso_domain("acme", DOMAIN)
.expect("the rig proves acme's domain");
let csv = format!("user_name,display_name,email,active\nfay@{DOMAIN},Fay,fay@{DOMAIN},true\ngus@{DOMAIN},Gus,gus@{DOMAIN},true\n,Nobody,,true\n");
let check = h.import(&csv, true).await;
assert!(check.dry_run);
assert_eq!((check.created, check.errors.len()), (2, 1), "{check:?}");
assert!(
check.errors[0].contains('4') || check.errors[0].contains("Nobody"),
"the bad row is named: {:?}",
check.errors
);
assert!(h.employees().is_empty(), "Check wrote");
let done = h.import(&csv, false).await;
assert_eq!((done.created, done.errors.len()), (2, 1), "{done:?}");
assert_eq!(
h.employees(),
vec!["acme.fay".to_owned(), "acme.gus".to_owned()]
);
let (fay, _) = h.oidc_login("fay").await;
let fay = fay.expect("fay through Keycloak");
assert!(!fay.created, "{fay:?}");
assert_eq!(fay.principal.unwrap().id, "acme.fay");
let off = h
.import(&format!("user_name,active\ngus@{DOMAIN},false\n"), false)
.await;
assert_eq!(off.deactivated, 1, "{off:?}");
let (gus, _) = h.oidc_login("gus").await;
let gus = gus.expect_err("gus deactivated by the import");
assert_eq!(gus.code(), Code::PermissionDenied);
assert!(
gus.message().contains("deactivated"),
"refused by name: {}",
gus.message()
);
let dir = h.directory().await;
let row = |id: &str| dir.iter().find(|u| u.principal_id == id).cloned().unwrap();
assert!(
row("acme.fay").source == "csv" && row("acme.fay").bound && row("acme.fay").active,
"{:?}",
row("acme.fay")
);
assert!(
row("acme.gus").source == "csv" && !row("acme.gus").active,
"{:?}",
row("acme.gus")
);
let trail = h.audit();
assert!(
trail.contains("Sso.ImportUsers principal=acme scope=account:acme allow"),
"{trail}"
);
}
/// ★ **A34-2: an unverified e-mail never claims a provisioned employee.** SCIM provisions
/// hana; a Keycloak person whose e-mail is hana's but NOT verified (`email_verified=false`:
/// self-service sign-up, an edited profile) signs in: gunnar must refuse by name and attach
/// nothing. RED before A34-2 (the first matching user name claimed the employee).
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn an_unverified_keycloak_email_never_claims_a_provisioned_employee() {
let h = Harness::new("unverified", true);
h.idp.oidc_client("unverified", &h.client_id());
h.idp.user("unverified", "hana", "Hana", false);
h.set(h.oidc_provider(false, false, true))
.await
.expect("Keycloak configured");
let (bearer, _) = h.scim_token().await;
let email = format!("hana@{DOMAIN}");
let (status, made) = h.scim(&bearer, "POST", "/Users", serde_json::json!({"userName": email, "emails": [{"value": email, "primary": true}], "active": true})).await;
assert_eq!(status, 201, "{made}");
let (claim, _) = h.oidc_login("hana").await;
let refused = claim.expect_err("an unverified e-mail claimed a provisioned employee");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("verified"),
"refused by name: {}",
refused.message()
);
let row = h
.directory()
.await
.into_iter()
.find(|u| u.principal_id == "acme.hana")
.expect("hana in the Directory");
assert!(!row.bound, "the employee was attached: {row:?}");
}
// ── through the real router and gate (OX-WIRED4) ────────────────────────────
//
// Every test above calls `SsoService` with a `Caller` put in by hand, so the gate in front of
// a box (the console's signed key, an admin's passkey session, an anonymous call) is never
// asked. `tests/sso_signin_rpc.rs` drives the router against edda's fakes; this drives it
// against Keycloak: the `Sso` service a box serves, behind its gate, with gunnar's real
// egress behind the account's IdP CA on the operator's network.
fn free_addr() -> std::net::SocketAddr {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
l.local_addr().expect("addr")
}
/// 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
}
/// A user-verified passkey login through the identity core: the session token.
fn passkey_session(identity: &gunnar_grpc::identity::GunnarIdentity, by: &Authenticator) -> String {
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
}
/// **One box serving the real router**: its gate in front of the real `CoreControlPlane`
/// on a real store; the console is the box's first admin by its signed key; acme's and
/// globex's admins hold stepped-up passkey sessions. The egress is the Harness's.
struct Served {
_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
ledger: Arc<Ledger>,
idp: Idp,
realm: String,
at: String,
console: gunnar_core::ssh_key::PrivateKey,
acme: String,
globex: String,
stop: Option<tokio::sync::oneshot::Sender<()>>,
}
impl Drop for Served {
fn drop(&mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
}
}
impl Served {
async fn start(realm: &str) -> Served {
let idp = Idp::from_env();
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let pem = gunnar_core::mint_ed25519().expect("mint");
let console =
gunnar_core::ssh_key::PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap())
.expect("parse");
let line = console.public_key().to_openssh().expect("line");
stores
.auth()
.bootstrap_admin(&line)
.expect("the console is the box's 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 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(Arc::clone(&identity));
ctx.sso.set_private_nets(
PrivateNets::parse(&idp.nets).expect("the IdP's network is a private one"),
);
ctx.sso
.set_egress(Arc::new(gunnar_server::sso_egress::UreqEgress::with_nets(
false,
gunnar_server::sso_egress::system_lookup(),
ctx.sso.private_nets_handle(),
)));
ctx.sso.set_egress_on(true);
idp.realm(realm);
let addr = free_addr();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0)
.serve_with_shutdown(addr, async {
let _ = rx.await;
})
.await;
});
let t0 = std::time::Instant::now();
while std::net::TcpStream::connect(addr).is_err() {
assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
tokio::time::sleep(Duration::from_millis(25)).await;
}
let acme = passkey_session(&identity, &acme_key);
let globex = passkey_session(&identity, &globex_key);
Served {
_dir: dir,
stores,
plane,
ledger,
idp,
realm: realm.to_owned(),
at: addr.to_string(),
console,
acme,
globex,
stop: Some(tx),
}
}
fn sso(
&self,
) -> gunnar_proto::sso_client::SsoClient<gunnar_grpc::client_signer::ControlChannel> {
gunnar_proto::sso_client::SsoClient::new(
gunnar_grpc::client_signer::plain_channel(&self.at).expect("a channel"),
)
}
/// `Access.WhoAmI` through the router: who the gate takes `session` for.
async fn who(&self, session: &str) -> Result<String, tonic::Status> {
gunnar_proto::access_client::AccessClient::new(
gunnar_grpc::client_signer::plain_channel(&self.at).expect("a channel"),
)
.who_am_i(with_session(session, WhoAmIRequest {}))
.await
.map(|r| r.into_inner().principal.map(|p| p.id).unwrap_or_default())
}
fn signed<T>(&self, body: T) -> Request<T> {
gunnar_grpc::client_signer::authed(&self.console, body)
}
async fn set(
&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 begin(&self, account: &str) -> BeginResponse {
self.sso()
.begin(self.signed(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(self.signed(request))
.await
.map(|r| r.into_inner())
}
/// One OIDC sign-in of `local` through Keycloak's login form, relayed by the console.
async fn oidc_login(&self, local: &str) -> Result<FinishResponse, tonic::Status> {
let begun = self.begin("acme").await;
assert_eq!(begun.protocol, SsoProtocol::Oidc as i32);
assert!(
begun
.redirect_url
.starts_with(&self.idp.issuer(&self.realm)),
"{}",
begun.redirect_url
);
let back = match self.idp.sign_in(&begun.redirect_url, local) {
Answer::Redirect(l) => l,
Answer::Page(s, p) => {
panic!("Keycloak did not send {local} back: {s} {}", cut(&p, 600))
}
};
assert!(
back.starts_with(&format!("{ORIGIN}/auth/sso/oidc/callback")),
"{back}"
);
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
}
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
}
fn audit(&self) -> String {
self.ledger.0.lock().unwrap().join("\n")
}
}
/// ★ **Keycloak sign-ins through the real router and gate** (OX-WIRED4). OIDC: only acme's
/// admin, by a stepped-up passkey session, sets acme's provider (globex's admin and an
/// anonymous call are refused); the console (the box's first admin, its signed key)
/// discovers, begins and finishes; Keycloak's own login form signs bob in, he is a NEW
/// employee of acme just in time, and the session the finish hands is a credential the gate
/// takes as acme.bob (`Access.WhoAmI`); the second sign-in is the same person; the account
/// admin's session is no relay. SAML: globex's admin sets Keycloak's metadata by URL behind
/// the IdP CA; carol signs in through Keycloak's form, her session is a credential, a
/// replayed response is refused. The client secret is in no answer and no audit record.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs a real Keycloak: cargo xtask idp"]
async fn keycloak_sign_ins_go_through_the_real_router_and_gate() {
let b = Served::start("router").await;
let client_id = "gunnar-router".to_owned();
b.idp.oidc_client("router", &client_id);
b.idp.user("router", "bob", "Bob", true);
b.idp.user("router", "carol", "Carol", true);
let oidc = SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Oidc as i32,
display_name: "Acme Keycloak".into(),
domains: vec![DOMAIN.into()],
oidc: Some(OidcSettings {
issuer: b.idp.issuer("router"),
client_id,
username_claim: "email".into(),
idp_ca_pem: b.idp.ca_pem.clone(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
};
// Only acme's admin sets acme's provider.
let outsider = b.set(&b.globex, oidc.clone()).await.unwrap_err();
assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
let anon = b
.sso()
.set_provider(Request::new(SetProviderRequest {
provider: Some(oidc.clone()),
client_secret: SECRET.into(),
..Default::default()
}))
.await
.unwrap_err();
assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
let view = b
.set(&b.acme, oidc)
.await
.expect("Sso.SetProvider through the gate reads Keycloak behind acme's CA");
assert!(view.provider.as_ref().expect("provider").has_client_secret);
assert!(
!format!("{view:?}").contains(SECRET),
"the client secret came back"
);
// The domain proof through the router is `tests/sso_signin_rpc.rs`'s (a DNS answer);
// here the rig proves it in the table, as the Harness does.
b.stores
.auth()
.verify_sso_domain("acme", DOMAIN)
.expect("the rig proves acme's domain");
let found = b
.sso()
.discover(b.signed(DiscoverRequest {
hint: format!("Bob@{}", DOMAIN.to_uppercase()),
}))
.await
.expect("Sso.Discover through the gate")
.into_inner();
assert!(
found.found && found.account == "acme" && found.display_name == "Acme Keycloak",
"{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 first = b
.oidc_login("bob")
.await
.expect("Keycloak's ID token, verified through the router");
assert!(first.created, "{first:?}");
let bob = first.principal.as_ref().expect("principal").id.clone();
assert_eq!(bob, "acme.bob");
assert_eq!(first.account, "acme");
assert_eq!(
b.stores.auth().employer_of(&bob).unwrap().as_deref(),
Some("acme")
);
assert_eq!(
b.who(&first.session)
.await
.expect("the session is a credential at the gate"),
"acme.bob"
);
assert_eq!(
b.who("not-a-session").await.unwrap_err().code(),
Code::Unauthenticated
);
let second = b.oidc_login("bob").await.expect("the second sign-in");
assert!(!second.created);
assert_eq!(second.principal.unwrap().id, bob);
assert_eq!(b.employees("acme"), vec![bob.clone()]);
// SAML: globex's provider is Keycloak's SAML client in the same realm.
let entity = format!("{ORIGIN}/auth/sso/saml/globex");
b.idp.saml_client("router", &entity);
let saml = SsoProvider {
account: "globex".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Globex Keycloak SAML".into(),
saml: Some(SamlSettings {
idp_metadata_url: format!("{}/protocol/saml/descriptor", b.idp.issuer("router")),
..Default::default()
}),
oidc: Some(OidcSettings {
idp_ca_pem: b.idp.ca_pem.clone(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
};
let outsider = b.set(&b.acme, saml.clone()).await.unwrap_err();
assert_eq!(
outsider.code(),
Code::PermissionDenied,
"acme's admin set globex's provider: {outsider:?}"
);
let view = b
.set(&b.globex, saml)
.await
.expect("the metadata by URL, through the gate");
assert_eq!(
view.provider.unwrap().saml.unwrap().idp_entity_id,
b.idp.issuer("router")
);
let begun = b.begin("globex").await;
assert_eq!(begun.protocol, SsoProtocol::Saml as i32);
let (relay_state, response) = match b.idp.sign_in(&begun.redirect_url, "carol") {
Answer::Page(200, page) => (
hidden(&page, "RelayState").unwrap_or_default(),
hidden(&page, "SAMLResponse").unwrap_or_else(|| {
panic!("no SAMLResponse on Keycloak's page: {}", cut(&page, 600))
}),
),
Answer::Page(s, page) => panic!(
"Keycloak answered the SAML sign-in {s}: {}",
cut(&page, 600)
),
Answer::Redirect(l) => panic!("a SAML sign-in redirected to {l}"),
};
assert_eq!(relay_state, begun.state, "the RelayState comes back");
let saml_finish = |state: &str| FinishRequest {
state: state.into(),
saml_response: response.clone(),
console_origin: ORIGIN.into(),
..Default::default()
};
let not_relay = b
.sso()
.finish(with_session(&b.globex, saml_finish(&relay_state)))
.await
.unwrap_err();
assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
let ok = b
.finish(saml_finish(&relay_state))
.await
.expect("Keycloak's signed response, through the router");
assert!(ok.created, "{ok:?}");
let carol = ok.principal.expect("principal").id;
assert_eq!(ok.account, "globex");
assert_eq!(
b.who(&ok.session)
.await
.expect("the session is a credential at the gate"),
carol
);
let again = b.begin("globex").await;
let replay = b
.finish(saml_finish(&again.state))
.await
.expect_err("a replayed response");
assert_eq!(replay.code(), Code::PermissionDenied, "{replay:?}");
assert_eq!(b.employees("globex"), vec![carol.clone()]);
let trail = b.audit();
assert!(
trail.contains("Sso.SetProvider principal=acme scope=account:acme allow"),
"{trail}"
);
assert!(
trail.contains("Sso.Finish principal=acme.bob scope=account:acme allow"),
"{trail}"
);
assert!(
trail.contains(&format!(
"Sso.Finish principal={carol} scope=account:globex allow"
)),
"{trail}"
);
assert!(
!trail.contains(SECRET),
"the client secret reached the audit trail"
);
}
// ── the run's certificate (a step of `cargo xtask idp`, not a test of gunnar) ─
/// **The run's CA and Keycloak's certificate**, written to `GUNNAR_IDP_MINT_DIR` (`ca.pem`,
/// `tls/kc.crt`, `tls/kc.key`) before Keycloak starts: the xtask is dependency-free, and rcgen
/// is already this crate's dev-dependency. The key is a throwaway, for one run's IdP.
#[test]
#[ignore = "a step of cargo xtask idp: mints the run's IdP certificate"]
fn mint_the_runs_idp_certificate() {
let dir = std::path::PathBuf::from(env("GUNNAR_IDP_MINT_DIR"));
let ca_key = rcgen::KeyPair::generate().expect("a key");
let mut ca = rcgen::CertificateParams::new(Vec::<String>::new()).expect("params");
ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
ca.distinguished_name.push(
rcgen::DnType::CommonName,
"acme's own CA (a cargo xtask idp run)",
);
let ca_cert = ca.self_signed(&ca_key).expect("the CA");
let issuer = rcgen::Issuer::new(ca, ca_key);
let leaf_key = rcgen::KeyPair::generate().expect("a key");
let leaf = rcgen::CertificateParams::new(vec!["idp.acme.test".to_owned()]).expect("params");
let leaf_cert = leaf.signed_by(&leaf_key, &issuer).expect("the leaf");
std::fs::create_dir_all(dir.join("tls")).unwrap();
std::fs::write(dir.join("ca.pem"), ca_cert.pem()).unwrap();
std::fs::write(
dir.join("tls/kc.crt"),
format!("{}{}", leaf_cert.pem(), ca_cert.pem()),
)
.unwrap();
std::fs::write(dir.join("tls/kc.key"), leaf_key.serialize_pem()).unwrap();
}