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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/sso.rs

//! **Administration → Identity provider** (company SSO, 2026-10-05): the
//! account admin's surface — the account's identity provider, its SCIM
//! bearers, its directory and the CSV import.
//!
//! Every call is signed AS THE PERSON ([`Actor`]); gunnar's `Sso` service is
//! the account admin's (Admin over the account's namespace) and decides. This
//! console holds none of it: the client secret is passed through once and
//! never comes back, a SCIM bearer is shown once.
//!
//! Under `/api/account/`, so any signed-in session reaches the handlers and
//! gunnar admits the account admin alone.

use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;

use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

fn invalid(message: &str) -> ApiError {
    ApiError(Fail {
        code: "invalidargument".to_owned(),
        message: message.to_owned(),
    })
}

fn account_param(q: &HashMap<String, String>) -> Result<String, ApiError> {
    q.get("account")
        .map(|a| a.trim().to_owned())
        .filter(|a| !a.is_empty())
        .ok_or_else(|| invalid("`account` is required"))
}

fn protocol_word(p: i32) -> &'static str {
    match pb::SsoProtocol::try_from(p).unwrap_or(pb::SsoProtocol::Unspecified) {
        pb::SsoProtocol::Oidc => "oidc",
        pb::SsoProtocol::Saml => "saml",
        pb::SsoProtocol::Unspecified => "",
    }
}

fn jit_word(jit: i32) -> &'static str {
    match pb::SsoJit::try_from(jit).unwrap_or(pb::SsoJit::Off) {
        pb::SsoJit::Open => "open",
        pb::SsoJit::Domains => "domains",
        pb::SsoJit::Off => "off",
    }
}

fn view_out(view: Option<pb::ProviderView>) -> Value {
    let Some(v) = view else { return Value::Null };
    let proofs: Vec<Value> = v
        .domain_proofs
        .iter()
        .map(|d| json!({ "domain": d.domain, "txt_name": d.txt_name, "txt_value": d.txt_value, "verified": d.verified }))
        .collect();
    let p = v.provider.unwrap_or_default();
    let oidc = p.oidc.unwrap_or_default();
    let saml = p.saml.unwrap_or_default();
    json!({
        "account": p.account,
        "protocol": protocol_word(p.protocol),
        "display_name": p.display_name,
        "domains": p.domains,
        "verified_domains": p.verified_domains,
        "domain_proofs": proofs,
        "jit": jit_word(p.jit),
        "require_sso": p.require_sso,
        "require_passkey": p.require_passkey,
        "has_client_secret": p.has_client_secret,
        "updated_at_unix_ms": p.updated_at_unix_ms,
        "updated_by": p.updated_by,
        "oidc": {
            "issuer": oidc.issuer,
            "client_id": oidc.client_id,
            "scopes": oidc.scopes,
            "username_claim": oidc.username_claim,
            "display_name_claim": oidc.display_name_claim,
            "ca_fingerprints": oidc.idp_ca_fingerprints,
        },
        "saml": {
            "metadata_url": saml.idp_metadata_url,
            "entity_id": saml.idp_entity_id,
            "sso_url": saml.idp_sso_url,
            "certificate_fingerprints": saml.idp_certificates,
            "username_attribute": saml.username_attribute,
            "display_name_attribute": saml.display_name_attribute,
        },
        "redirect_uri": v.redirect_uri,
        "sp_entity_id": v.sp_entity_id,
        "acs_url": v.acs_url,
        "sp_metadata_xml": v.sp_metadata_xml,
    })
}

/// gunnar's addresses for this console, as the page shows them (A34-47).
pub(crate) fn addresses_out(a: &pb::ConsoleAddresses) -> Value {
    json!({
        "scim_base_url": a.scim_base_url,
        "login_url": a.login_url,
        "redirect_uri": a.redirect_uri,
        "acs_url": a.acs_url,
        "sp_entity_id": a.sp_entity_id,
    })
}

/// A SCIM bearer as the page lists it: gunnar's fields, its warning
/// included (A34-27).
fn token_out(t: &pb::ScimToken) -> Value {
    json!({
        "token_id": t.token_id, "label": t.label, "created_at_unix_ms": t.created_at_unix_ms,
        "created_by": t.created_by, "last_used_unix_ms": t.last_used_unix_ms,
        "expires_at_unix_ms": t.expires_at_unix_ms, "read_only": t.read_only, "attention": t.attention,
    })
}

fn user_out(u: &pb::DirectoryUser) -> Value {
    json!({
        "principal_id": u.principal_id,
        "user_name": u.user_name,
        "display_name": u.display_name,
        "email": u.email,
        "active": u.active,
        "source": u.source,
        "bound": u.bound,
        "rebind": u.rebind,
        "last_login_unix_ms": u.last_login_unix_ms,
    })
}

/// `GET /api/account/sso?account=` — the provider (or `null`), the bearers,
/// the directory, and the addresses the admin registers at the IdP.
pub async fn read(
    State(state): State<AppState>,
    actor: Actor,
    Query(q): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
    let account = account_param(&q)?;
    let mut sso = actor.gunnar.sso();
    let origin = state
        .webauthn
        .as_deref()
        .map(|w| w.origin().trim_end_matches('/').to_owned())
        .unwrap_or_default();
    // A34-47: the addresses are gunnar's, for this console's origin; the
    // console renders them and builds none.
    let provider = sso
        .get_provider(pb::GetProviderRequest {
            account: account.clone(),
            console_origin: origin,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let view = provider.view;
    let addresses = provider.addresses.unwrap_or_default();
    let tokens = sso
        .list_scim_tokens(pb::ListScimTokensRequest {
            account: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .tokens;
    let directory = sso
        .list_directory(pb::ListDirectoryRequest {
            account: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let mut shown = addresses_out(&addresses);
    shown["account"] = json!(account);
    shown["provider"] = view_out(view);
    shown["scim_tokens"] = tokens.iter().map(token_out).collect::<Vec<_>>().into();
    shown["users"] = directory.users.iter().map(user_out).collect::<Vec<_>>().into();
    shown["groups"] = json!(directory.groups);
    shown["csv_max_bytes"] = json!(nornir_identity_directory::csv::CSV_MAX_BYTES);
    Ok(Json(shown))
}

/// The body of `PUT /api/account/sso`.
#[derive(Debug, Default, Deserialize)]
pub struct ProviderIn {
    pub account: String,
    /// `oidc` or `saml`.
    pub protocol: String,
    #[serde(default)]
    pub display_name: String,
    #[serde(default)]
    pub domains: Vec<String>,
    /// The three policy fields are required (RA5-47): a save names its policy, and a body
    /// without one is refused, never defaulted (a default here widened Domains/Off to Open).
    #[serde(default)]
    pub require_sso: Option<bool>,
    #[serde(default)]
    pub require_passkey: Option<bool>,
    /// Who may become a new employee just in time: `open`, `domains`, `off`
    /// (A34-25); gunnar decides what each means.
    #[serde(default)]
    pub jit: String,
    #[serde(default)]
    pub issuer: String,
    #[serde(default)]
    pub client_id: String,
    /// Write-only; empty keeps the stored one.
    #[serde(default)]
    pub client_secret: String,
    /// Remove the stored client secret (A34-48).
    #[serde(default)]
    pub clear_secret: bool,
    #[serde(default)]
    pub scopes: Vec<String>,
    #[serde(default)]
    pub username_claim: String,
    /// The IdP's own CA (PEM), for a company IdP behind its own CA; empty
    /// keeps the stored one. gunnar judges it and trusts it for this
    /// account's IdP alone.
    #[serde(default)]
    pub ca_pem: String,
    /// Remove the stored IdP CA.
    #[serde(default)]
    pub clear_ca: bool,
    #[serde(default)]
    pub metadata_url: String,
    #[serde(default)]
    pub metadata_xml: String,
    #[serde(default)]
    pub entity_id: String,
    #[serde(default)]
    pub sso_url: String,
    #[serde(default)]
    pub certificate: String,
    #[serde(default)]
    pub username_attribute: String,
}

impl std::fmt::Display for ProviderIn {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{} {} (secret {}, IdP CA {})",
            self.account,
            self.protocol,
            if self.clear_secret {
                "cleared"
            } else if self.client_secret.is_empty() {
                "kept"
            } else {
                "set"
            },
            if self.clear_ca {
                "cleared"
            } else if self.ca_pem.trim().is_empty() {
                "kept"
            } else {
                "pasted"
            }
        )
    }
}

/// Refused: a save that does not name its policy (RA5-47).
const POLICY_REQUIRED: &str = "`jit` (open, domains or off), `require_sso` and `require_passkey` are required: a save names its policy";

/// **The page's body as gunnar's `Sso.SetProvider`** — field for field;
/// gunnar judges every one (the thin layer).
pub(crate) fn set_provider_request(body: &ProviderIn, origin: &str) -> Result<pb::SetProviderRequest, ApiError> {
    let protocol = match body.protocol.trim() {
        "oidc" => pb::SsoProtocol::Oidc,
        "saml" => pb::SsoProtocol::Saml,
        _ => return Err(invalid("`protocol` is oidc or saml")),
    };
    let provider = pb::SsoProvider {
        account: body.account.trim().to_owned(),
        protocol: protocol as i32,
        display_name: body.display_name.trim().to_owned(),
        domains: body
            .domains
            .iter()
            .map(|d| d.trim().to_owned())
            .filter(|d| !d.is_empty())
            .collect(),
        // The account's IdP CA travels in the one CA field for both
        // protocols: a SAML provider's metadata URL is read trusting it too
        // (A34-41). An empty field keeps the stored one (gunnar's rule).
        oidc: match protocol {
            pb::SsoProtocol::Oidc => Some(pb::OidcSettings {
                issuer: body.issuer.trim().to_owned(),
                client_id: body.client_id.trim().to_owned(),
                scopes: body.scopes.clone(),
                username_claim: body.username_claim.trim().to_owned(),
                display_name_claim: String::new(),
                idp_ca_pem: body.ca_pem.clone(),
                idp_ca_fingerprints: Vec::new(),
            }),
            _ => (!body.ca_pem.trim().is_empty()).then(|| pb::OidcSettings {
                idp_ca_pem: body.ca_pem.clone(),
                ..Default::default()
            }),
        },
        saml: (protocol == pb::SsoProtocol::Saml).then(|| pb::SamlSettings {
            idp_metadata_xml: body.metadata_xml.clone(),
            idp_metadata_url: body.metadata_url.trim().to_owned(),
            idp_entity_id: body.entity_id.trim().to_owned(),
            idp_sso_url: body.sso_url.trim().to_owned(),
            idp_certificates: if body.certificate.trim().is_empty() {
                Vec::new()
            } else {
                vec![body.certificate.clone()]
            },
            username_attribute: body.username_attribute.trim().to_owned(),
            display_name_attribute: String::new(),
        }),
        require_sso: body.require_sso.ok_or_else(|| invalid(POLICY_REQUIRED))?,
        require_passkey: body.require_passkey.ok_or_else(|| invalid(POLICY_REQUIRED))?,
        console_origin: origin.to_owned(),
        jit: match body.jit.trim() {
            "open" => pb::SsoJit::Open,
            "domains" => pb::SsoJit::Domains,
            "off" => pb::SsoJit::Off,
            "" => return Err(invalid(POLICY_REQUIRED)),
            _ => return Err(invalid("`jit` is open, domains or off")),
        } as i32,
        ..Default::default()
    };
    Ok(pb::SetProviderRequest {
        provider: Some(provider),
        client_secret: body.client_secret.clone(),
        clear_client_secret: body.clear_secret,
        clear_idp_ca: body.clear_ca,
    })
}

/// `PUT /api/account/sso` — configure the account's identity provider.
pub async fn write(
    State(state): State<AppState>,
    actor: Actor,
    Json(body): Json<ProviderIn>,
) -> Result<Json<Value>, ApiError> {
    let origin = state
        .webauthn
        .as_deref()
        .map(|w| w.origin().to_owned())
        .ok_or_else(|| invalid("this console has no origin, so it cannot sign anybody in"))?;
    let request = set_provider_request(&body, &origin)?;
    let saved = actor
        .gunnar
        .sso()
        .set_provider(request)
        .await
        .map_err(ApiError::from)?
        .into_inner();
    tracing::info!(target: crate::auth::AUDIT_TARGET, event = "account.sso_provider", user = %actor.who, provider = %body, "the account admin configured the identity provider");
    // The login page's company field asks again now, not a minute from now.
    state.caches.sso_offered.invalidate().await;
    Ok(Json(json!({ "provider": view_out(saved.view) })))
}

/// `DELETE /api/account/sso?account=`.
pub async fn remove(
    State(state): State<AppState>,
    actor: Actor,
    Query(q): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
    let account = account_param(&q)?;
    let removed = actor
        .gunnar
        .sso()
        .remove_provider(pb::RemoveProviderRequest { account })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .removed;
    // The login page's company field asks again now (the last one may be gone).
    state.caches.sso_offered.invalidate().await;
    Ok(Json(json!({ "removed": removed })))
}

#[derive(Debug, Deserialize)]
pub struct TokenIn {
    pub account: String,
    #[serde(default)]
    pub label: String,
    /// 0: never expires (gunnar refuses past a year).
    #[serde(default)]
    pub expires_in_days: u32,
    #[serde(default)]
    pub read_only: bool,
}

/// `POST /api/account/sso/scim-tokens` — a SCIM bearer: its secret here once.
pub async fn create_token(
    actor: Actor,
    Json(body): Json<TokenIn>,
) -> Result<Json<Value>, ApiError> {
    let made = actor
        .gunnar
        .sso()
        .create_scim_token(pb::CreateScimTokenRequest {
            account: body.account.trim().to_owned(),
            label: body.label.trim().to_owned(),
            expires_in_days: body.expires_in_days,
            read_only: body.read_only,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let token = made.token.unwrap_or_default();
    Ok(Json(
        json!({ "token_id": token.token_id, "label": token.label, "secret": made.secret }),
    ))
}

/// `DELETE /api/account/sso/scim-tokens/{id}?account=`.
pub async fn revoke_token(
    actor: Actor,
    Path(id): Path<String>,
    Query(q): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
    let account = account_param(&q)?;
    let revoked = actor
        .gunnar
        .sso()
        .revoke_scim_token(pb::RevokeScimTokenRequest {
            account,
            token_id: id,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .revoked;
    Ok(Json(json!({ "revoked": revoked })))
}

#[derive(Debug, Deserialize)]
pub struct ImportIn {
    pub account: String,
    pub csv: String,
    #[serde(default)]
    pub dry_run: bool,
}

/// The most bytes `POST /api/account/sso/import` takes: the directory's one
/// limit, as JSON (A34-44). Past it axum refuses before the handler runs.
pub const IMPORT_BODY_MAX: usize = nornir_identity_directory::csv::REQUEST_MAX_BYTES;

/// An import job as the page reads it (A34-17): gunnar's fields.
pub(crate) fn import_out(done: &pb::ImportUsersResponse) -> Value {
    json!({
        "job_id": done.job_id, "state": done.state, "error": done.error,
        "done_rows": done.done_rows, "total_rows": done.total_rows,
        "created": done.created, "updated": done.updated, "deactivated": done.deactivated,
        "reactivated": done.reactivated, "unchanged": done.unchanged, "errors": done.errors, "dry_run": done.dry_run,
        "ended_sessions": done.ended_sessions,
    })
}

/// The people a finished import deactivated are signed out here too, as for
/// SCIM's deactivation and the Directory toggle (A34-9).
async fn sign_out_ended(state: &AppState, done: &pb::ImportUsersResponse) {
    if done.state == "done" && !done.ended_sessions.is_empty() {
        if let Some(webauthn) = state.webauthn.as_deref() {
            crate::auth::sso::signed_out_by_gunnar(webauthn, &done.ended_sessions);
        }
    }
}

/// `POST /api/account/sso/import` — the CSV import, queued at gunnar as a job
/// (A34-17); the answer is the job as it stands. A CSV past the one limit is
/// refused by its name (A34-44).
pub async fn import(State(state): State<AppState>, actor: Actor, Json(body): Json<ImportIn>) -> Result<Json<Value>, ApiError> {
    if body.csv.len() > nornir_identity_directory::csv::CSV_MAX_BYTES {
        return Err(invalid(&nornir_identity_directory::csv::too_large()));
    }
    let done = actor
        .gunnar
        .sso()
        .import_users(pb::ImportUsersRequest {
            account: body.account.trim().to_owned(),
            csv: body.csv,
            dry_run: body.dry_run,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    sign_out_ended(&state, &done).await;
    Ok(Json(import_out(&done)))
}

/// `GET /api/account/sso/import?account=&job_id=` — where the import job is.
pub async fn import_status(
    State(state): State<AppState>,
    actor: Actor,
    Query(q): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
    let account = account_param(&q)?;
    let done = actor
        .gunnar
        .sso()
        .import_status(pb::ImportStatusRequest {
            account,
            job_id: q.get("job_id").map(|j| j.trim().to_owned()).unwrap_or_default(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    sign_out_ended(&state, &done).await;
    Ok(Json(import_out(&done)))
}

#[derive(Debug, Deserialize)]
pub struct ActiveIn {
    pub account: String,
    pub active: bool,
}

/// `POST /api/account/sso/users/{principal}/active` — deactivate (gated,
/// sessions revoked, data kept) or reactivate one person.
pub async fn set_active(
    State(state): State<AppState>,
    actor: Actor,
    Path(principal): Path<String>,
    Json(body): Json<ActiveIn>,
) -> Result<Json<Value>, ApiError> {
    let user = actor
        .gunnar
        .sso()
        .set_user_active(pb::SetUserActiveRequest {
            account: body.account.trim().to_owned(),
            principal_id: principal,
            active: body.active,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .user
        .unwrap_or_default();
    // gunnar deactivated them and ended their sessions: signed out here too.
    if !user.active && !user.principal_id.is_empty() {
        if let Some(webauthn) = state.webauthn.as_deref() {
            crate::auth::sso::signed_out_by_gunnar(webauthn, std::slice::from_ref(&user.principal_id));
        }
    }
    Ok(Json(user_out(&user)))
}

#[derive(Debug, Deserialize)]
pub struct AccountIn {
    pub account: String,
}

/// `POST /api/account/sso/domains/{domain}/verify` — ask gunnar to read the
/// domain's DNS proof (A34-5); gunnar decides.
pub async fn verify_domain(actor: Actor, Path(domain): Path<String>, Json(body): Json<AccountIn>) -> Result<Json<Value>, ApiError> {
    let view = actor
        .gunnar
        .sso()
        .verify_domain(pb::VerifyDomainRequest { account: body.account.trim().to_owned(), domain })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .view;
    Ok(Json(json!({ "provider": view_out(view) })))
}

#[derive(Debug, Deserialize)]
pub struct RebindIn {
    pub account: String,
    #[serde(default)]
    pub principal_ids: Vec<String>,
    #[serde(default)]
    pub external_id: String,
}

/// `POST /api/account/sso/rebind` — open a rebind (A34-11, an IdP migration):
/// the named employees (every bound one when none is named); gunnar decides.
pub async fn rebind(actor: Actor, Json(body): Json<RebindIn>) -> Result<Json<Value>, ApiError> {
    let users = actor
        .gunnar
        .sso()
        .rebind_users(pb::RebindUsersRequest {
            account: body.account.trim().to_owned(),
            principal_ids: body.principal_ids,
            external_id: body.external_id.trim().to_owned(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .users;
    Ok(Json(json!({ "users": users.iter().map(user_out).collect::<Vec<_>>() })))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **The account's IdP CA crosses the console untouched** (owner
    /// 2026-10-06): the pasted PEM is gunnar's to judge, the remove box is
    /// gunnar's `clear_idp_ca`, and an answer shows gunnar's fingerprints —
    /// never a certificate.
    #[test]
    fn the_idp_ca_is_relayed_and_shown_by_its_fingerprint() {
        let body: ProviderIn = serde_json::from_value(json!({
            "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "oidc", "issuer": "https://10.0.2.100:8443/realms/acme",
            "client_id": "gunnar", "ca_pem": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
        }))
        .expect("the page's body");
        let asked = set_provider_request(&body, "https://test2.gunnar.rs").expect("a request");
        let oidc = asked.provider.as_ref().and_then(|p| p.oidc.clone()).expect("oidc");
        assert_eq!(oidc.idp_ca_pem, body.ca_pem, "the PEM as pasted");
        assert!(!asked.clear_idp_ca);
        let clear: ProviderIn = serde_json::from_value(json!({ "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "oidc", "clear_ca": true })).expect("body");
        assert!(set_provider_request(&clear, "https://test2.gunnar.rs").expect("a request").clear_idp_ca);
        assert!(!format!("{body}").contains("BEGIN"), "the audit line names no certificate: {body}");
        let shown = view_out(Some(pb::ProviderView {
            provider: Some(pb::SsoProvider {
                oidc: Some(pb::OidcSettings { idp_ca_fingerprints: vec!["SHA256:abc".into()], ..Default::default() }),
                ..Default::default()
            }),
            ..Default::default()
        }));
        assert_eq!(shown["oidc"]["ca_fingerprints"], json!(["SHA256:abc"]));
        assert!(!shown.to_string().contains("BEGIN"));
    }

    /// ★ **A34-5 + A34-25 on the page's wire**: the JIT word reaches gunnar as
    /// its enum (an unknown word is refused here), and the answer shows each
    /// domain's TXT record and whether it is proven.
    #[test]
    fn the_jit_policy_and_the_domain_proofs_cross_the_console() {
        for (word, jit) in [("open", pb::SsoJit::Open), ("domains", pb::SsoJit::Domains), ("off", pb::SsoJit::Off)] {
            let body: ProviderIn = serde_json::from_value(json!({ "account": "acme", "protocol": "oidc", "jit": word, "require_sso": false, "require_passkey": false })).expect("body");
            assert_eq!(set_provider_request(&body, "https://c.example").expect("a request").provider.unwrap().jit, jit as i32, "{word}");
        }
        let odd: ProviderIn = serde_json::from_value(json!({ "account": "acme", "protocol": "oidc", "jit": "sometimes" })).expect("body");
        assert!(set_provider_request(&odd, "https://c.example").is_err());
        let shown = view_out(Some(pb::ProviderView {
            provider: Some(pb::SsoProvider { jit: pb::SsoJit::Domains as i32, verified_domains: vec!["acme.example".into()], ..Default::default() }),
            domain_proofs: vec![pb::DomainProof { domain: "acme.example".into(), txt_name: "_sso-proof.acme.example".into(), txt_value: "sso-proof=ab".into(), verified: true }],
            ..Default::default()
        }));
        assert_eq!(shown["jit"], "domains");
        assert_eq!(shown["domain_proofs"][0]["txt_name"], "_sso-proof.acme.example");
        assert_eq!(shown["domain_proofs"][0]["verified"], true);
        assert_eq!(shown["verified_domains"], json!(["acme.example"]));
    }

    /// ★ **RA5-47: a save names its policy, never a default.** The relay mapped a missing
    /// `jit` to Open (and a missing `require_sso`/`require_passkey` to off), so a PUT without
    /// them, or a page that saved before its load, widened Domains/Off to Open: anyone the
    /// IdP vouches for got an account. A body without any of the three is refused.
    #[test]
    fn ra5_47_a_save_names_its_policy_never_a_default() {
        for missing in ["jit", "require_sso", "require_passkey"] {
            let mut body = json!({ "account": "acme", "protocol": "oidc", "jit": "domains", "require_sso": true, "require_passkey": true });
            body.as_object_mut().unwrap().remove(missing);
            let body: ProviderIn = serde_json::from_value(body).expect("body");
            assert!(set_provider_request(&body, "https://c.example").is_err(), "a PUT without {missing} took a default");
        }
        let empty: ProviderIn = serde_json::from_value(json!({ "account": "acme", "protocol": "oidc", "jit": "", "require_sso": true, "require_passkey": false })).expect("body");
        assert!(set_provider_request(&empty, "https://c.example").is_err(), "an empty jit is no policy");
    }

    /// ★ **A34-48: the remove box for the client secret is gunnar's `clear_client_secret`.**
    #[test]
    fn a34_48_the_client_secret_can_be_cleared() {
        let body: ProviderIn = serde_json::from_value(json!({
            "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "oidc", "issuer": "https://idp.acme.example", "client_id": "gunnar",
            "clear_secret": true,
        }))
        .expect("the page's body");
        let asked = set_provider_request(&body, "https://test2.gunnar.rs").expect("a request");
        assert!(asked.clear_client_secret, "the remove box reaches gunnar");
        assert!(asked.client_secret.is_empty());
        let kept: ProviderIn = serde_json::from_value(json!({ "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "oidc" })).expect("body");
        assert!(!set_provider_request(&kept, "https://test2.gunnar.rs").expect("a request").clear_client_secret);
        assert!(format!("{body}").contains("secret cleared"), "{body}");
    }

    /// ★ **A34-41: a SAML provider carries the account's IdP CA too** (gunnar reads the
    /// metadata URL trusting it): the pasted PEM goes as the account's CA, the remove box as
    /// `clear_idp_ca`, and the answer's fingerprints are shown.
    #[test]
    fn a34_41_a_saml_provider_carries_the_accounts_idp_ca() {
        let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
        let body: ProviderIn = serde_json::from_value(json!({
            "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "saml", "metadata_url": "https://idp.acme.example/metadata", "ca_pem": pem,
        }))
        .expect("the page's body");
        let asked = set_provider_request(&body, "https://test2.gunnar.rs").expect("a request");
        let provider = asked.provider.expect("a provider");
        assert_eq!(provider.saml.expect("saml").idp_metadata_url, "https://idp.acme.example/metadata");
        assert_eq!(provider.oidc.map(|o| o.idp_ca_pem).unwrap_or_default(), pem, "the account's CA as pasted");
        let clear: ProviderIn = serde_json::from_value(json!({ "account": "acme", "jit": "open", "require_sso": false, "require_passkey": false, "protocol": "saml", "clear_ca": true })).expect("body");
        assert!(set_provider_request(&clear, "https://test2.gunnar.rs").expect("a request").clear_idp_ca);
    }

    /// ★ **A34-47: the addresses are gunnar's.** The page shows the server's
    /// fields as they came; nothing here builds one.
    #[test]
    fn the_addresses_are_rendered_as_gunnar_sent_them() {
        let shown = addresses_out(&pb::ConsoleAddresses {
            redirect_uri: "https://srv.example/r".into(),
            sp_entity_id: "https://srv.example/e/ac%20me".into(),
            acs_url: "https://srv.example/a".into(),
            login_url: "https://srv.example/l/ac%20me".into(),
            scim_base_url: "https://srv.example/s".into(),
        });
        assert_eq!(shown["redirect_uri"], "https://srv.example/r");
        assert_eq!(shown["sp_entity_id"], "https://srv.example/e/ac%20me");
        assert_eq!(shown["login_url"], "https://srv.example/l/ac%20me");
        assert_eq!(shown["scim_base_url"], "https://srv.example/s");
        let code = include_str!("sso.rs");
        let code = &code[..code.find("#[cfg(test)]").unwrap()];
        for built in [concat!("/auth/sso/oidc", "/callback"), concat!("/login/sso", "/{"), concat!("/auth/sso/saml", "/acs"), concat!("{origin}", "/scim")] {
            assert!(!code.contains(built), "the console builds {built}");
        }
    }

    /// ★ **A34-9: an import's ended sessions reach the page**, and the job's
    /// words are gunnar's.
    #[test]
    fn an_import_job_is_shown_with_its_ended_sessions() {
        let shown = import_out(&pb::ImportUsersResponse {
            job_id: "sso-import-1".into(),
            state: "done".into(),
            deactivated: 1,
            ended_sessions: vec!["acme.ben".into()],
            ..Default::default()
        });
        assert_eq!(shown["state"], "done");
        assert_eq!(shown["ended_sessions"], json!(["acme.ben"]));
        const { assert!(IMPORT_BODY_MAX > nornir_identity_directory::csv::CSV_MAX_BYTES) };
    }
}