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(¬_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:?}");
}