crates/nornir-auth-oidc/tests/keycloak.rs
//! ★ **The OIDC bearer provider against a REAL Keycloak** (owner 2026-10-26, lane T-OWNERFLOW).
//! This is the provider Holger dispatches an `Oidc` auth method to (holger's default build has
//! `online`). The tokens are Keycloak's own access tokens, signed by its realm key, for two
//! clients of one realm: `holger-acme` (its audience mapper puts `holger-acme` in `aud`) and
//! `other-app` (any other application of the same company directory).
//!
//! Keycloak runs in a container started by gunnar's **`cargo xtask idp`** (it runs this file
//! beside gunnar's own `sso_keycloak` tests when edda is checked out next to gunnar). Every test
//! is `#[ignore]`d; the xtask runs them with `--ignored --features online`. The environment
//! (all set by the xtask): `GUNNAR_IDP_HTTP_URL` (Keycloak's plain port on the run's private
//! network), `GUNNAR_IDP_ADMIN_PASSWORD_FILE` (a FILE, never printed).
//!
//! Each test has its OWN realm: the provider's key-set cache is process-wide and keyed by
//! issuer, so a realm per test keeps one test's keys out of another's.
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use std::time::Duration;
use nornir_auth_oidc::OidcProvider;
use nornir_service::provider::{AuthProvider as _, ProviderError};
const PASSWORD: &str = "a-test-password-for-a-test-realm";
fn env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("[idp-env] {name} is not set: run these with gunnar's `cargo xtask idp`"))
}
/// One plain HTTP/1.1 request (the run's private network; no TLS here): (status, body).
fn http(method: &str, url: &str, headers: &[(&str, &str)], body: &str) -> (u16, String) {
let rest = url.strip_prefix("http://").expect("an http URL");
let (authority, path) = rest.split_once('/').map(|(a, p)| (a, format!("/{p}"))).unwrap_or((rest, "/".into()));
let mut s = TcpStream::connect(authority).unwrap_or_else(|e| panic!("{authority}: {e}"));
s.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len());
for (k, v) in headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("\r\n");
req.push_str(body);
s.write_all(req.as_bytes()).unwrap();
let mut raw = Vec::new();
s.read_to_end(&mut raw).unwrap();
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
let status: u16 = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
let body = if head.to_ascii_lowercase().contains("transfer-encoding: chunked") { dechunk(body) } else { body.to_owned() };
(status, body)
}
fn dechunk(mut s: &str) -> String {
let mut out = String::new();
while let Some((size, rest)) = s.split_once("\r\n") {
let n = usize::from_str_radix(size.trim(), 16).unwrap_or(0);
if n == 0 || rest.len() < n {
break;
}
out.push_str(&rest[..n]);
s = rest[n..].trim_start_matches("\r\n");
}
out
}
fn form(pairs: &[(&str, &str)]) -> String {
let pct = |s: &str| s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>();
pairs.iter().map(|(k, v)| format!("{}={}", pct(k), pct(v))).collect::<Vec<_>>().join("&")
}
struct Keycloak {
base: String,
admin_password: String,
realm: String,
}
impl Keycloak {
/// A fresh realm with the two clients and one person.
fn realm(realm: &str) -> Keycloak {
let k = Keycloak {
base: env("GUNNAR_IDP_HTTP_URL").trim_end_matches('/').to_owned(),
admin_password: std::fs::read_to_string(env("GUNNAR_IDP_ADMIN_PASSWORD_FILE")).expect("the admin password file").trim().to_owned(),
realm: realm.to_owned(),
};
let _ = k.admin("DELETE", &format!("/{realm}"), serde_json::Value::Null);
k.admin_ok("POST", "", serde_json::json!({"realm": realm, "enabled": true, "sslRequired": "none"}));
for (client, audience) in [("holger-acme", true), ("other-app", false)] {
let mut c = serde_json::json!({
"clientId": client, "secret": "a-test-client-secret", "publicClient": false,
"directAccessGrantsEnabled": true, "standardFlowEnabled": false,
});
if audience {
c["protocolMappers"] = serde_json::json!([{
"name": "holger audience", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper",
"config": {"included.client.audience": client, "access.token.claim": "true", "id.token.claim": "false"},
}]);
}
k.admin_ok("POST", &format!("/{realm}/clients"), c);
}
k.admin_ok(
"POST",
&format!("/{realm}/users"),
serde_json::json!({
"username": "ivy", "email": "ivy@acme.mock.invalid", "firstName": "Ivy", "lastName": "Example",
"enabled": true, "emailVerified": true,
"credentials": [{"type": "password", "value": PASSWORD, "temporary": false}],
}),
);
k
}
fn issuer(&self) -> String {
format!("{}/realms/{}", self.base, self.realm)
}
fn admin(&self, method: &str, path: &str, body: serde_json::Value) -> (u16, String) {
let (status, t) = http(
"POST",
&format!("{}/realms/master/protocol/openid-connect/token", self.base),
&[("Content-Type", "application/x-www-form-urlencoded")],
&form(&[("grant_type", "password"), ("client_id", "admin-cli"), ("username", "admin"), ("password", &self.admin_password)]),
);
assert_eq!(status, 200, "the Keycloak admin sign-in was refused (body withheld)");
let token = serde_json::from_str::<serde_json::Value>(&t).unwrap()["access_token"].as_str().unwrap().to_owned();
let auth = format!("Bearer {token}");
let json = if body.is_null() { String::new() } else { body.to_string() };
http(method, &format!("{}/admin/realms{path}", self.base), &[("Authorization", &auth), ("Content-Type", "application/json")], &json)
}
fn admin_ok(&self, method: &str, path: &str, body: serde_json::Value) {
let (status, text) = self.admin(method, path, body);
assert!((200..300).contains(&status), "Keycloak admin {method} {path}: {status} {text}");
}
/// ivy's access token, as `client` gets it from Keycloak.
fn token(&self, client: &str) -> String {
let (status, t) = http(
"POST",
&format!("{}/protocol/openid-connect/token", self.issuer()),
&[("Content-Type", "application/x-www-form-urlencoded")],
&form(&[("grant_type", "password"), ("client_id", client), ("client_secret", "a-test-client-secret"), ("username", "ivy"), ("password", PASSWORD), ("scope", "openid")]),
);
assert_eq!(status, 200, "Keycloak gave {client} no token: {t}");
serde_json::from_str::<serde_json::Value>(&t).unwrap()["access_token"].as_str().unwrap().to_owned()
}
fn jwks(&self) -> String {
let (status, body) = http("GET", &format!("{}/protocol/openid-connect/certs", self.issuer()), &[], "");
assert_eq!(status, 200);
body
}
}
/// ★ **Keycloak's own access tokens, verified offline against its realm's keys** (the key set
/// pre-provisioned, holger's airgap configuration): the token Keycloak issued for holger is
/// accepted as ivy; the token it issued to ANOTHER application of the same directory is
/// refused, authoritatively (`Invalid`), never "not mine".
#[tokio::test]
#[ignore = "needs a real Keycloak: gunnar's cargo xtask idp"]
async fn keycloak_access_tokens_for_holger_pass_and_another_apps_are_refused() {
let kc = Keycloak::realm("holger-offline");
let provider = OidcProvider::inline(kc.issuer(), Some("holger-acme".into()), kc.jwks());
let ours = provider.verify_bearer(&kc.token("holger-acme")).await.expect("holger's token").expect("claimed");
assert!(!ours.subject.is_empty(), "{ours:?}");
let other = provider.verify_bearer(&kc.token("other-app")).await;
assert!(matches!(other, Err(ProviderError::Invalid)), "another application's token was not refused: {:?}", other.map(|p| p.map(|p| p.subject)));
}
/// ★ **A34-1: a JWT that fails verification is refused, never handed to `/userinfo`.** Holger's
/// marketplace configuration names the issuer, the audience and a `jwks_uri` only. When that key
/// endpoint does not answer with keys (moved, wrong, an IdP hiccup), today's provider does not
/// refuse another application's token: it falls through to a guessed `{issuer}/userinfo`, which
/// checks neither issuer nor audience. Here Keycloak's key endpoint is named wrong; the token
/// Keycloak issued to `other-app` must come back `Invalid`. RED on today's code (`Ok(None)`:
/// "not mine", after the `/userinfo` call); GREEN since nornir-auth-oidc 0.2.0.
#[tokio::test]
#[ignore = "needs a real Keycloak: gunnar's cargo xtask idp"]
async fn a_jwt_that_fails_verification_is_refused_and_never_sent_to_userinfo() {
let kc = Keycloak::realm("holger-a34-1");
let provider = OidcProvider {
issuer_url: kc.issuer(),
audience: Some("holger-acme".into()),
jwks: None,
jwks_path: None,
jwks_uri: Some(format!("{}/protocol/openid-connect/certs-moved", kc.issuer())),
..OidcProvider::default()
};
let other = provider.verify_bearer(&kc.token("other-app")).await;
assert!(matches!(other, Err(ProviderError::Invalid)), "a JWT that failed verification was not refused: {:?}", other.map(|p| p.map(|p| p.subject)));
}