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