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/anonymous/sso_offered.rs

//! **The login page's company field is gunnar's to offer** (round 5.3, owner
//! 2026-10-08: test2's first page showed "Work email or company account" on an
//! estate where no account has an identity provider — "what's this?").
//!
//! `/auth/status` says `sso_offered`: gunnar's `Sso.Discover` asked with no
//! hint, whose `any_provider` is an existence bit that names no account. The
//! page draws the field only when it is true; the first page is otherwise one
//! passkey button.

// Its tests are the `sso` build's (A34-15); the fake's `Sso` is served in every build.
#![cfg_attr(not(feature = "sso"), allow(dead_code, unused_imports))]

use std::sync::atomic::Ordering;

use tonic::{Request, Response, Status};

use crate::pb;

use super::{console, send, FakeGunnar};

/// `Sso.Discover` as gunnar answers it: `any_provider` on every hint, the empty
/// one included, and nobody found for the empty hint.
#[tonic::async_trait]
impl pb::sso_server::Sso for FakeGunnar {
    async fn discover(&self, request: Request<pb::DiscoverRequest>) -> Result<Response<pb::DiscoverResponse>, Status> {
        self.discovers.fetch_add(1, Ordering::SeqCst);
        let any_provider = self.sso_providers.load(Ordering::SeqCst);
        let hint = request.into_inner().hint;
        Ok(Response::new(if any_provider && hint.ends_with("@acme.example") {
            pb::DiscoverResponse { found: true, account: "acme".into(), display_name: "Acme".into(), protocol: pb::SsoProtocol::Oidc as i32, any_provider }
        } else {
            pb::DiscoverResponse { any_provider, ..Default::default() }
        }))
    }

    async fn verify_domain(&self, request: Request<pb::VerifyDomainRequest>) -> Result<Response<pb::VerifyDomainResponse>, Status> {
        let asked = request.into_inner();
        self.sso_clients.lock().expect("sso_clients").push(format!("verify {} {}", asked.account, asked.domain));
        Ok(Response::new(pb::VerifyDomainResponse { view: Some(pb::ProviderView::default()) }))
    }

    async fn rebind_users(&self, request: Request<pb::RebindUsersRequest>) -> Result<Response<pb::RebindUsersResponse>, Status> {
        let asked = request.into_inner();
        self.sso_clients.lock().expect("sso_clients").push(format!("rebind {} {:?} {}", asked.account, asked.principal_ids, asked.external_id));
        Ok(Response::new(pb::RebindUsersResponse::default()))
    }

    async fn begin(&self, request: Request<pb::BeginRequest>) -> Result<Response<pb::BeginResponse>, Status> {
        self.sso_clients.lock().expect("sso_clients").push(request.into_inner().client);
        match self.sso_begin.lock().expect("sso_begin").clone() {
            Ok(redirect_url) => Ok(Response::new(pb::BeginResponse { redirect_url, state: "flow-1".into(), ..Default::default() })),
            Err(message) => Err(Status::failed_precondition(message)),
        }
    }
}

/// `GET /login/sso/acme` as a browser sends it: the status and `Location`.
async fn sign_in_with_acme(app: &axum::Router) -> (http::StatusCode, String) {
    use tower::ServiceExt;
    let response = app
        .clone()
        .oneshot(http::Request::get("/login/sso/acme").body(axum::body::Body::empty()).expect("a request"))
        .await
        .expect("the console answers");
    let to = response
        .headers()
        .get(http::header::LOCATION)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_owned();
    (response.status(), to)
}

/// ★ **Only an https identity provider is a place to send a browser**
/// (stinky 2026-10-10 #11). `Sso.Begin`'s `redirect_url` is the account's
/// own configuration — an account anybody can buy — and the console sent the
/// browser there whatever it said, an open redirect off the console's domain.
#[cfg(feature = "sso")]
#[tokio::test]
async fn a_company_sign_in_goes_only_to_an_https_identity_provider() {
    let fake = FakeGunnar::new();
    let (app, _, _) = console(fake.clone(), false).await;
    let (status, to) = sign_in_with_acme(&app).await;
    assert_eq!(status, http::StatusCode::SEE_OTHER);
    assert_eq!(to, "https://idp.acme.example/authorize?state=s");

    for bad in [
        "http://idp.acme.example/authorize",
        "javascript:alert(1)",
        "//evil.example/authorize",
        "https:///authorize",
        "https://user@evil.example/authorize",
        "data:text/html,hi",
        "",
    ] {
        *fake.sso_begin.lock().expect("sso_begin") = Ok(bad.to_owned());
        let (status, to) = sign_in_with_acme(&app).await;
        assert_eq!(status, http::StatusCode::SEE_OTHER, "{bad}");
        assert!(to.starts_with("/login?sso_error="), "{bad:?} sent the browser to {to:?}");
    }
}

/// ★ **The login page says only the console's own sentences** (stinky
/// 2026-10-10 #12). `?sso_error=` carried free text — gunnar's message, or
/// whatever a link-maker typed — and the page showed it on the console's own
/// domain. It now carries a code; the words are the page's own.
#[cfg(feature = "sso")]
#[tokio::test]
async fn a_refused_company_sign_in_comes_back_as_a_code_not_a_sentence() {
    let fake = FakeGunnar::new();
    *fake.sso_begin.lock().expect("sso_begin") = Err("Call +1 555 0100 to unlock your account".to_owned());
    let (app, _, _) = console(fake.clone(), false).await;
    let (_, to) = sign_in_with_acme(&app).await;
    let code = to.strip_prefix("/login?sso_error=").expect("back to the login page");
    assert!(
        !code.is_empty() && code.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
        "not a code: {code:?}"
    );
    assert!(!to.contains("555"), "gunnar's sentence went to the page: {to}");
    assert!(
        crate::auth::sso::SsoRefusal::ALL.iter().any(|r| r.code() == code),
        "not one of the console's codes: {code:?}"
    );

    // Every code the console sends has the page's own words, said the same.
    let html = crate::auth::routes::login_html();
    for refusal in crate::auth::sso::SsoRefusal::ALL {
        let line = format!("  {:?}: {:?},", refusal.code(), refusal.words());
        assert!(html.contains(&line), "the page has no words for {:?}", refusal.code());
    }
}

fn offered(body: &str) -> Option<bool> {
    serde_json::from_str::<serde_json::Value>(body).ok()?.get("sso_offered")?.as_bool()
}

/// ★ **No identity provider anywhere: no company field.** The status says
/// `sso_offered: false`, asked of gunnar once and kept — a second status read
/// in the same minute does not ask again.
#[cfg(feature = "sso")]
#[tokio::test]
async fn a_box_where_no_account_has_an_identity_provider_offers_no_company_field() {
    let fake = FakeGunnar::new();
    let (app, _, _) = console(fake.clone(), false).await;
    let (status, body) = send(&app, http::Method::GET, "/auth/status", None).await;
    assert_eq!(status, http::StatusCode::OK, "{body}");
    assert_eq!(offered(&body), Some(false), "no provider, and the status offers the field: {body}");
    let (_, again) = send(&app, http::Method::GET, "/auth/status", None).await;
    assert_eq!(offered(&again), Some(false));
    assert_eq!(fake.discovers.load(Ordering::SeqCst), 1, "the answer is kept: gunnar was asked once for two status reads");
}

/// ★ **An account with an identity provider: the field is offered** — and the
/// status names no account for it, only that one exists.
#[cfg(feature = "sso")]
#[tokio::test]
async fn a_box_where_an_account_has_an_identity_provider_offers_the_company_field() {
    let fake = FakeGunnar::new();
    fake.sso_providers.store(true, Ordering::SeqCst);
    let (app, _, _) = console(fake.clone(), false).await;
    let (status, body) = send(&app, http::Method::GET, "/auth/status", None).await;
    assert_eq!(status, http::StatusCode::OK, "{body}");
    assert_eq!(offered(&body), Some(true), "a provider exists, and the status does not offer the field: {body}");
    assert!(!body.contains("acme"), "the status named an account: {body}");
}

/// **The page draws what it is given**: the company field ships hidden and is
/// revealed only on `sso_offered === true` (or on the way back from a company
/// sign-in gunnar refused).
#[cfg(feature = "sso")]
#[test]
fn the_login_page_shows_the_company_field_only_when_the_server_offers_it() {
    let html = crate::auth::routes::login_html();
    assert!(html.contains(r#"<details id="sso" hidden>"#), "the company field is drawn before the server said so");
    assert!(html.contains("#sso[hidden] { display: none; }"));
    assert!(html.contains("if (s.sso_offered === true) {"), "the page decides by itself");
    assert!(html.contains("sso.hidden = false;"));
}

/// ★ i54a run18 (cov SSO2): **a person the company's directory deactivated comes back with
/// that said** — gunnar refuses the sign-in naming the deactivation, and the console sends
/// its own code `deactivated` (never gunnar's sentence) with the page's own words; any other
/// refusal stays `refused`.
#[cfg(feature = "sso")]
#[test]
fn a_person_the_company_deactivated_comes_back_as_deactivated() {
    use crate::auth::sso::{finish_refusal, SsoRefusal};
    let gone = tonic::Status::permission_denied("single sign-on refused: [sso-deactivated] this person is deactivated in the account's directory");
    assert_eq!(finish_refusal(&gone), SsoRefusal::Deactivated);
    // By its code, never by a sentence (A34-46): the old words alone are no code.
    let sentence = tonic::Status::permission_denied("single sign-on refused: nina is deactivated in acme's directory");
    assert_eq!(finish_refusal(&sentence), SsoRefusal::Refused, "a sentence was matched");
    assert_eq!(SsoRefusal::Deactivated.code(), "deactivated");
    assert!(SsoRefusal::ALL.contains(&SsoRefusal::Deactivated), "a code the page has words for");
    assert!(SsoRefusal::Deactivated.words().contains("deactivated"), "{}", SsoRefusal::Deactivated.words());
    let other = tonic::Status::permission_denied("single sign-on refused: the identity provider said \"no\"");
    assert_eq!(finish_refusal(&other), SsoRefusal::Refused);
    let not_gunnars = tonic::Status::invalid_argument("nina is deactivated in acme's directory");
    assert_eq!(finish_refusal(&not_gunnars), SsoRefusal::Refused, "only gunnar's refusal of the person");
}

/// ★ **gunnar is told whose sign-in it is** (A34-6): the relay names the browser's
/// address on `Sso.Begin`, so gunnar counts begins, open flows and failures per
/// (account, client) and a few addresses cannot lock a company out.
#[cfg(feature = "sso")]
#[tokio::test]
async fn a_company_sign_in_names_the_browsers_address_to_gunnar() {
    use tower::ServiceExt;
    let fake = FakeGunnar::new();
    let (app, _, _) = console(fake.clone(), false).await;
    let mut request = http::Request::get("/login/sso/acme").body(axum::body::Body::empty()).expect("a request");
    request.extensions_mut().insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(([203, 0, 113, 7], 5555))));
    let response = app.clone().oneshot(request).await.expect("the console answers");
    assert_eq!(response.status(), http::StatusCode::SEE_OTHER);
    assert_eq!(fake.sso_clients.lock().expect("sso_clients").as_slice(), ["203.0.113.7"], "gunnar was not told the browser's address");
}

/// ★ **A34-46: gunnar's refusal codes become the page's own codes**: each coded
/// refusal has a console code with the page's words, and an uncoded one is
/// `refused`.
#[cfg(feature = "sso")]
#[test]
fn gunnars_refusal_codes_become_the_pages_own() {
    use crate::auth::sso::{finish_refusal, SsoRefusal};
    let said = |code: &str| finish_refusal(&tonic::Status::permission_denied(format!("single sign-on refused: [{code}] words")));
    assert_eq!(said("sso-jit-off"), SsoRefusal::NotListed);
    assert_eq!(said("sso-jit-domain"), SsoRefusal::NotListed);
    assert_eq!(said("sso-claim-domain"), SsoRefusal::NotListed);
    assert_eq!(said("sso-claim-bound"), SsoRefusal::NotListed);
    assert_eq!(said("sso-locked"), SsoRefusal::Locked);
    assert_eq!(said("sso-busy"), SsoRefusal::Busy);
    assert_eq!(said("sso-expired"), SsoRefusal::Expired);
    assert_eq!(said("sso-egress-off"), SsoRefusal::Unavailable);
    assert_eq!(said("sso-named-nobody"), SsoRefusal::NamedNobody);
    assert_eq!(said("sso-idp-refused"), SsoRefusal::Refused);
    let html = crate::auth::routes::login_html();
    for r in [SsoRefusal::NotListed, SsoRefusal::Locked, SsoRefusal::Expired, SsoRefusal::Unavailable] {
        assert!(SsoRefusal::ALL.contains(&r));
        assert!(html.contains(&format!("\"{}\": \"{}\"", r.code(), r.words())), "the page has no words for {}", r.code());
    }
}

/// ★ **A34-39: the discovery door is tight**: an address asks at most
/// `DISCOVER_PER_ADDRESS_PER_MINUTE` times a minute.
#[cfg(feature = "sso")]
#[tokio::test]
async fn the_discovery_door_answers_one_address_a_few_times_a_minute() {
    use tower::ServiceExt;
    let (app, _, _) = console(FakeGunnar::new(), false).await;
    let ask = || {
        let mut r = http::Request::get("/auth/sso/discover?hint=a%40acme.example").body(axum::body::Body::empty()).expect("a request");
        r.extensions_mut().insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(([198, 51, 100, 39], 4000))));
        r
    };
    let mut answered = 0;
    for _ in 0..30 {
        if app.clone().oneshot(ask()).await.expect("an answer").status() == http::StatusCode::OK {
            answered += 1;
        }
    }
    assert!(answered <= 10, "one address discovered {answered} times in a minute");
    assert!(answered >= 1);
}

/// ★ **A34-5 / A34-11: the admin's two verbs reach gunnar**: proving a domain
/// and opening a rebind, signed as the person.
#[cfg(feature = "sso")]
#[tokio::test]
async fn the_admin_proves_a_domain_and_opens_a_rebind_through_gunnar() {
    use tower::ServiceExt;
    let fake = FakeGunnar::new();
    let (app, keys, _) = console(fake.clone(), false).await;
    let cookie = keys.make_session("acme", crate::role::Role::Admin, true);
    for (uri, body) in [
        ("/api/account/sso/domains/acme.example/verify", r#"{"account":"acme"}"#),
        ("/api/account/sso/rebind", r#"{"account":"acme","principal_ids":["acme.bob"],"external_id":"new-sub"}"#),
    ] {
        let request = http::Request::post(uri)
            .header(http::header::COOKIE, format!("{}={cookie}", crate::auth::SESSION_COOKIE))
            .header(http::header::CONTENT_TYPE, "application/json")
            .header(http::header::ORIGIN, "https://console.example")
            .body(axum::body::Body::from(body))
            .expect("a request");
        let answer = app.clone().oneshot(request).await.expect("an answer");
        assert_eq!(answer.status(), http::StatusCode::OK, "{uri}");
    }
    let asked = fake.sso_clients.lock().expect("sso_clients").clone();
    assert!(asked.contains(&"verify acme acme.example".to_owned()), "{asked:?}");
    assert!(asked.contains(&r#"rebind acme ["acme.bob"] new-sub"#.to_owned()), "{asked:?}");
}