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

nordisk/gunnar-ui

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