Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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