uitest/src/sso.rs
//! **Company single sign-on** (owner, 2026-10-05) — the account admin points
//! the account at the company's identity provider, a person of the company
//! signs in with it and is a NEW employee of the account, and a person the
//! company's directory deactivated (SCIM) is refused. Driven as people drive
//! it, against the rig's FAKE identity provider (`--fake-idp`, never a real
//! one), and held against the SERVER's answer.
//!
//! `person` is kim's session: a viewer of this console, and the admin of
//! kim's own account (`/kim`).
use std::time::Duration;
use crate::cdp::Cdp;
use crate::http;
use crate::json::esc;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
fn type_into(c: &mut Cdp, sess: &str, id: &str, value: &str) -> Result<(), String> {
let done = c.eval(
sess,
&format!(
"(() => {{ const i = document.getElementById({}); if (!i) return false;
const proto = i.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const set = Object.getOwnPropertyDescriptor(proto, 'value').set;
set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
esc(id),
esc(value)
),
)?;
want(done.as_bool() == Some(true), || {
format!("no #{id} on the page")
})
}
/// Tell the fake IdP who signs in next.
fn fake_person(r: &Rig, sub: &str, email: &str, name: &str) -> Result<(), String> {
let idp = r.fake_idp.as_ref().ok_or("no fake IdP")?;
let addr = idp.control.trim_start_matches("http://");
let pct = |s: &str| {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect::<String>()
};
let resp = http::req(
addr,
"GET",
&format!(
"/person?sub={}&email={}&name={}",
pct(sub),
pct(email),
pct(name)
),
&[],
None,
)?;
want(resp.status == 200, || {
format!("the fake IdP's control door answered {}", resp.status)
})
}
/// A whole company sign-in in a FRESH browser (its own cookie jar): the login
/// page, "Sign in with your company", the hint, Continue — through the fake
/// IdP and back. Returns the session's page and `/auth/status`.
fn sign_in_with_company(
c: &mut Cdp,
r: &Rig,
hint: &str,
) -> Result<(String, crate::json::Json), String> {
let sess = c.isolated_page()?;
c.navigate(&sess, &format!("{}/login", r.origin()))?;
c.wait_for(&sess, "!!document.getElementById('sso-go')", 30_000)?;
c.eval(
&sess,
"(() => { document.getElementById('sso').open = true; return 1; })()",
)?;
type_into(c, &sess, "sso-hint", hint)?;
c.eval(
&sess,
"(() => { document.getElementById('sso-go').click(); return 1; })()",
)?;
// Out to the IdP and back: wait until the browser is on this console again
// and settled, on a page that is not the login page — or back on the login
// page with gunnar's refusal in #msg.
let deadline = std::time::Instant::now() + Duration::from_secs(45);
loop {
std::thread::sleep(Duration::from_millis(250));
let at = c.eval_str(&sess, "location.origin + location.pathname + '|' + document.readyState + '|' + ((document.getElementById('msg') || {textContent: ''}).textContent)").unwrap_or_default();
let home = at.starts_with(&r.origin());
let settled = at.contains("|complete|");
let refused = at.contains("/login|")
&& at.split('|').nth(2).is_some_and(|m| {
m.contains("refused") || m.contains("deactivated") || m.contains("not")
});
if home && settled && (!at.contains("/login|") || refused) {
break;
}
if std::time::Instant::now() > deadline {
return Err(format!("the sign-in did not come back within 45 s: {at}"));
}
}
let status = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
Ok((sess, status))
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, person: &str) {
const NAMES: [&str; 4] = [
"the-account-admin-configures-the-company-identity-provider",
"the-login-page-offers-company-sign-in-once-an-account-has-an-identity-provider",
"a-person-signs-in-with-the-company-and-is-a-new-employee",
"a-person-the-directory-deactivated-is-refused",
];
let Some(idp) = r.fake_idp.clone() else {
for name in NAMES {
go(rows, name, || {
Err("NOT RUN: no --fake-idp (the fake company identity provider is what these rows sign in with)".into())
});
}
return;
};
let mut account = String::new();
let mut employee = String::new();
go(rows, NAMES[0], || {
let room = c.eval(person, "fetch('/api/account/room').then(r => r.json())")?;
account = room.text("namespace");
want(!account.is_empty(), || {
"the server names no account for kim's session".to_owned()
})?;
c.navigate(
person,
&format!("{}/admin/identity-provider?account={}", r.origin(), account),
)?;
c.wait_for(person, "!!document.getElementById('sso-save')", 30_000)
.map_err(|_| {
format!(
"no Identity provider form: {}",
http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)
)
})?;
type_into(c, person, "sso-display", "Kim Corp")?;
type_into(c, person, "sso-domains", &format!("{account}.example"))?;
type_into(c, person, "sso-issuer", &idp.oidc_issuer)?;
type_into(c, person, "sso-client-id", &idp.client_id)?;
type_into(c, person, "sso-client-secret", &idp.client_secret)?;
c.eval(
person,
"(() => { document.getElementById('sso-save').click(); return 1; })()",
)?;
c.wait_for(person, "!!document.getElementById('sso-saved') || !!document.querySelector('.fail-box, .fail')", 30_000)?;
let said = c.eval_str(
person,
"(document.getElementById('sso-saved') || {textContent: ''}).textContent",
)?;
want(said.contains("Saved"), || {
format!(
"the page says {}",
http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)
)
})?;
let raw = c.eval_str(
person,
&format!("fetch('/api/account/sso?account={account}').then(r => r.text())"),
)?;
want(!raw.contains(&idp.client_secret), || {
"the client secret came back from the server".to_owned()
})?;
let shown = crate::json::parse(&raw)?;
want(shown.text("provider.protocol") == "oidc", || {
format!("gunnar holds {}", http::cut(&raw, 300))
})?;
want(
shown
.path("provider.has_client_secret")
.and_then(|v| v.as_bool())
== Some(true),
|| "no client secret stored".to_owned(),
)?;
// A34-5: the domain leads to the account only once its DNS proof is read. The admin
// publishes the TXT record the page shows (here: at the rig's own resolver) and asks
// gunnar to read it.
let domain = format!("{account}.example");
let proof = shown
.path("provider.domain_proofs")
.and_then(|d| d.arr().iter().find(|p| p.text("domain") == domain).cloned())
.ok_or_else(|| format!("no DNS proof shown for {domain}: {}", http::cut(&raw, 300)))?;
crate::doh::publish(&proof.text("txt_name"), &proof.text("txt_value"))?;
let verified = c.eval(
person,
&format!(
"fetch('/api/account/sso/domains/' + encodeURIComponent({}) + '/verify', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}}})}}).then(async r => ({{status: r.status, body: await r.text()}}))",
esc(&domain),
esc(&account)
),
)?;
want(verified.path("status").and_then(|s| s.as_f64()) == Some(200.0), || {
format!("gunnar did not read {domain}'s proof: {} {}", verified.text("status"), http::cut(&verified.text("body"), 300))
})?;
let after = c.eval(person, &format!("fetch('/api/account/sso?account={account}').then(r => r.json())"))?;
let proven = after
.path("provider.domain_proofs")
.is_some_and(|d| d.arr().iter().any(|p| p.text("domain") == domain && p.path("verified").and_then(|v| v.as_bool()) == Some(true)));
want(proven, || format!("{domain} is not proven after the read: {}", http::cut(&after.dump(), 300)))?;
Ok(format!(
"{account}: OIDC at the fake IdP, secret stored and never shown; {domain} proven by DNS"
))
});
// ★ Round 5.3: the company field is gunnar's to offer — drawn only once an
// account here has an identity provider (the row above configured kim's).
// The first-page row holds the other half: none configured, no field.
go(rows, NAMES[1], || {
want(!account.is_empty(), || {
"no account (the row before failed)".to_owned()
})?;
let visitor = c.isolated_page()?;
c.navigate(&visitor, &format!("{}/login", r.origin()))?;
c.wait_for(&visitor, "(document.getElementById('sso') || {hidden: true}).hidden === false", 15_000)
.map_err(|_| "the company field stayed hidden although an account has an identity provider".to_owned())?;
let summary = c.eval_str(&visitor, "document.getElementById('sso-summary').textContent")?;
let status = c.eval(&visitor, "fetch('/auth/status', {credentials: 'omit'}).then(r => r.json())")?;
want(status.path("sso_offered").and_then(|v| v.as_bool()) == Some(true), || {
format!("/auth/status: {}", http::cut(&status.dump(), 300))
})?;
want(!status.dump().contains(&account), || {
format!("the status named the account: {}", http::cut(&status.dump(), 300))
})?;
Ok(format!("{summary:?} drawn; /auth/status sso_offered true, no account named"))
});
go(rows, NAMES[2], || {
want(!account.is_empty(), || {
"no account (the row before failed)".to_owned()
})?;
fake_person(
r,
"00u-dana",
&format!("dana@{account}.example"),
"Dana Example",
)?;
let (sess, status) = sign_in_with_company(c, r, &format!("dana@{account}.example"))?;
let at = c.eval_str(&sess, "location.pathname + location.search + ' | ' + (document.body ? document.body.innerText : '').replace(/\\s+/g, ' ').slice(0, 200)").unwrap_or_default();
want(
status.path("authenticated").and_then(|v| v.as_bool()) == Some(true),
|| format!("not signed in at {at}: {}", http::cut(&status.dump(), 200)),
)?;
employee = status.text("user");
want(employee == format!("{account}.dana"), || {
format!("signed in as {employee:?}, not {account}.dana")
})?;
let listed = c.eval(
person,
&format!("fetch('/api/account/employees?account={account}').then(r => r.json())"),
)?;
let names: Vec<String> = listed
.path("employees")
.map(|e| e.arr().iter().map(|p| p.text("id")).collect())
.unwrap_or_default();
want(names.contains(&employee), || {
format!(
"gunnar does not list {employee} as {account}'s employee: {}",
http::cut(&listed.dump(), 200)
)
})?;
Ok(format!(
"signed in through Kim Corp as {employee}, a NEW employee of {account}"
))
});
go(rows, NAMES[3], || {
want(!employee.is_empty(), || {
"no employee (the rows before failed)".to_owned()
})?;
let made = c.eval(person, &format!(
"fetch('/api/account/sso/scim-tokens', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}, label: 'uitest'}})}}).then(r => r.json())",
esc(&account)
))?;
let bearer = made.text("secret");
want(bearer.starts_with("gscim_"), || {
format!("no SCIM bearer: {}", http::cut(&made.dump(), 200))
})?;
let body = r#"{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"Replace","path":"active","value":"False"}]}"#;
let resp = http::req(
&r.ui_addr(),
"PATCH",
&format!("/scim/v2/Users/{employee}"),
&[
("Authorization", format!("Bearer {bearer}")),
("Content-Type", "application/scim+json".to_owned()),
],
Some(body),
)?;
want(
resp.status == 200 && resp.body.contains("\"active\":false"),
|| {
format!(
"SCIM PATCH answered {} {}",
resp.status,
http::cut(&resp.body, 200)
)
},
)?;
let wrong = http::req(
&r.ui_addr(),
"GET",
"/scim/v2/Users",
&[("Authorization", "Bearer gscim_wrong".to_owned())],
None,
)?;
want(wrong.status == 401, || {
format!("a wrong bearer was answered {}", wrong.status)
})?;
let (sess, status) = sign_in_with_company(c, r, &format!("dana@{account}.example"))?;
want(
status.path("authenticated").and_then(|v| v.as_bool()) != Some(true),
|| {
format!(
"a deactivated person signed in: {}",
http::cut(&status.dump(), 200)
)
},
)?;
let said = c.eval_str(
&sess,
"(document.getElementById('msg') || {textContent: ''}).textContent",
)?;
want(said.contains("deactivated"), || {
format!("the login page says {said:?}")
})?;
Ok(format!(
"SCIM deactivated {employee}; their sign-in is refused: {}",
http::cut(&said, 120)
))
});
}