Files
meguri/tests/oidc.rs
T
Aleksandr BogomiakovandClaude 548494f12e Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log
Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:12 +01:00

462 lines
16 KiB
Rust

//! End-to-end SSO over HTTP: the real Cot application, a real PostgreSQL database and a fake
//! OpenID provider. One test function because the instance configuration is process-wide.
#[path = "support/fake_oidc.rs"]
mod fake_oidc;
mod support;
use cot::{
AppBuilder,
config::{DatabaseConfig, ProjectConfig},
db::query,
project::RegisterAppsContext,
test::TestServerBuilder,
};
use fake_oidc::{CLIENT_ID, CLIENT_SECRET, Scenario};
use meguri::{
access::{self, Config},
identity::{self, BootstrapPasswords, SyncOptions},
models::{ExternalIdentityRow, UserRow},
oidc,
web::MeguriApp,
};
use reqwest::{Client, Response, StatusCode, redirect::Policy};
use serde_json::{Value, json};
use support::IsolatedDatabase;
struct TestProject {
database: String,
}
impl cot::Project for TestProject {
fn config(&self, _: &str) -> cot::Result<ProjectConfig> {
Ok(ProjectConfig::builder()
.debug(false)
.database(DatabaseConfig::builder().url(self.database.clone()).build())
.build())
}
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
apps.register_with_views(MeguriApp, "");
}
}
fn cookies(response: &Response) -> Vec<String> {
response
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_owned())
.collect()
}
fn cookie_value(response: &Response, name: &str) -> Option<String> {
cookies(response).iter().find_map(|c| {
let value = c.strip_prefix(&format!("{name}="))?.split(';').next()?;
(!value.is_empty()).then(|| value.to_owned())
})
}
fn location(response: &Response) -> String {
response
.headers()
.get("location")
.expect("redirect location")
.to_str()
.unwrap()
.to_owned()
}
fn claims(subject: &str, name: &str, groups: &[&str]) -> Value {
json!({
"sub":subject,"preferred_username":name,"name":format!("{name} Person"),
"email":format!("{name}@corp.test"),"groups":groups
})
}
struct Harness {
client: Client,
base: String,
idp: fake_oidc::Server,
}
impl Harness {
async fn begin(&self) -> (String, String) {
let response = self
.client
.get(format!("{}/api/oidc/login", self.base))
.send()
.await
.unwrap();
assert!(response.status().is_redirection(), "{}", response.status());
let flow = cookies(&response)
.into_iter()
.find(|c| c.starts_with("meguri_oidc="))
.unwrap();
for attribute in ["HttpOnly", "SameSite=Lax", "Path=/api/oidc", "Max-Age=600"] {
assert!(flow.contains(attribute), "{flow}");
}
let state = flow["meguri_oidc=".len()..].split(';').next().unwrap();
(location(&response), state.to_owned())
}
async fn callback(&self, query: &str, cookie: Option<&str>) -> Response {
let mut request = self
.client
.get(format!("{}/api/oidc/callback?{query}", self.base));
if let Some(cookie) = cookie {
request = request.header("cookie", format!("meguri_oidc={cookie}"));
}
request.send().await.unwrap()
}
async fn attempt(&self, scenario: Scenario) -> Response {
let (url, state) = self.begin().await;
let code = self.idp.authorize(&url, scenario);
self.callback(&format!("code={code}&state={state}"), Some(&state))
.await
}
async fn session(&self, token: &str) -> Value {
self.client
.get(format!("{}/api/session", self.base))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
async fn signed_in(&self, response: &Response) -> String {
assert!(response.status().is_redirection());
assert_eq!(location(response), "/", "{:?}", cookies(response));
cookie_value(response, "meguri_session").expect("session cookie")
}
fn refused(&self, response: &Response, code: &str) -> String {
assert!(response.status().is_redirection());
let target = location(response);
assert!(
target.starts_with(&format!("/?login_error={code}&ref=")),
"{target}"
);
assert!(
cookie_value(response, "meguri_session").is_none(),
"a refused sign-in never sets a session"
);
target.split("ref=").nth(1).unwrap().to_owned()
}
}
#[cot::e2e_test]
#[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"]
async fn sso_login_over_http_validates_tokens_maps_groups_and_never_leaks_a_session() {
let iso = IsolatedDatabase::new().await;
let idp = fake_oidc::Server::start().await;
let project = TestProject {
database: iso.url().await,
};
let server = TestServerBuilder::new(project).start().await;
let base = server.url();
// The process-wide settings can only be known once both servers have addresses.
// SAFETY: this binary has a single test function; nothing else reads the environment.
unsafe {
std::env::set_var("MEGURI_PUBLIC_URL", &base);
}
let config = Config::parse(&format!(
r#"
oidc: {{issuer: '{}', client_id: {CLIENT_ID}}}
instance_admin_groups: ['/meguri/admins']
workspaces:
- {{key: payments, name: Payments, admin_groups: ['/payments/leads'], member_groups: ['/payments/team']}}
- {{key: search, name: Search, admin_groups: ['/search/leads'], member_groups: ['/search/team']}}
"#,
idp.issuer()
))
.unwrap();
access::install(config.clone());
oidc::install(oidc::Settings {
config: config.oidc.clone().unwrap(),
client_secret: CLIENT_SECRET.into(),
redirect_uri: format!("{base}/api/oidc/callback"),
});
identity::sync_config(
&iso.store,
&config,
SyncOptions {
passwords: BootstrapPasswords::Derived,
now: chrono::Utc::now(),
allow_disable_all: false,
},
)
.await
.unwrap();
let h = Harness {
client: Client::builder().redirect(Policy::none()).build().unwrap(),
base: base.clone(),
idp,
};
let users = || async { UserRow::objects_count(&iso).await };
// The login screen offers SSO only; password sign-in is switched off for this instance.
let session: Value = h
.client
.get(format!("{base}/api/session"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(session["auth"], json!({"oidc":true,"password":false}));
assert!(session["user"].is_null());
let password = h
.client
.post(format!("{base}/api/login"))
.header("origin", &base)
.json(&json!({"username":"anyone","password":"anything-at-all"}))
.send()
.await
.unwrap();
assert_eq!(password.status(), StatusCode::UNAUTHORIZED);
// Registration and workspace creation no longer exist.
for (path, expected) in [("/api/register", StatusCode::NOT_FOUND)] {
let response = h
.client
.post(format!("{base}{path}"))
.header("origin", &base)
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}");
}
// Happy path: an employee of Payments signs in and gets exactly that.
let (url, _) = h.begin().await;
let url = reqwest::Url::parse(&url).unwrap();
assert_eq!(url.path(), "/realms/corp/protocol/openid-connect/auth");
let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query["redirect_uri"], format!("{base}/api/oidc/callback"));
assert!(query["state"].len() >= 32 && query["nonce"].len() >= 32);
let response = h
.attempt(Scenario::new(claims(
"u-1",
"carol",
&["/payments/team", "/other"],
)))
.await;
let token = h.signed_in(&response).await;
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_session=")
&& c.contains("HttpOnly")
&& c.contains("SameSite=Lax"))
);
assert!(
cookies(&response)
.iter()
.any(|c| c.starts_with("meguri_oidc=;") && c.contains("Max-Age=0")),
"the one-time flow cookie is cleared"
);
let session = h.session(&token).await;
assert_eq!(session["user"]["username"], "carol");
assert_eq!(session["user"]["email"], "carol@corp.test");
assert_eq!(session["user"]["instance_admin"], false);
assert_eq!(session["user"]["has_password"], false);
let workspaces: Value = h
.client
.get(format!("{base}/api/workspaces"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(workspaces["workspaces"].as_array().unwrap().len(), 1);
assert_eq!(workspaces["workspaces"][0]["key"], "payments");
// The administration API answers 403 to an ordinary employee.
let admin = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(admin.status(), StatusCode::FORBIDDEN);
assert_eq!(users().await, 1);
// Instance administrators and workspace leads come from their groups.
let response = h
.attempt(Scenario::new(claims("u-2", "root", &["/meguri/admins"])))
.await;
let admin_token = h.signed_in(&response).await;
assert_eq!(
h.session(&admin_token).await["user"]["instance_admin"],
true
);
let overview: Value = h
.client
.get(format!("{base}/api/admin/overview"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(overview["workspaces"].as_array().unwrap().len(), 2);
assert_eq!(overview["config"]["oidc_issuer"], idp_issuer(&h));
// Groups can come from userinfo when the ID token does not carry them.
let mut scenario =
Scenario::new(json!({"sub":"u-3","preferred_username":"lena","email":"lena@corp.test"}));
scenario.userinfo = Some(json!({"sub":"u-3","groups":["/search/leads"]}));
let response = h.attempt(scenario).await;
let lena = h.signed_in(&response).await;
assert_eq!(h.session(&lena).await["user"]["username"], "lena");
assert_eq!(h.idp.state.lock().unwrap().userinfo_requests, 1);
// Unmapped users and a provider that sends no groups get the same explanation and no account.
let before = users().await;
let reference = h.refused(
&h.attempt(Scenario::new(claims("u-4", "stranger", &["/unknown"])))
.await,
"no_access",
);
assert_eq!(reference.len(), 8);
// The reference on the screen leads an administrator to the stored reason and the groups
// the provider actually sent; ordinary users cannot read the event log.
let found: Value = h
.client
.get(format!("{base}/api/admin/events?search={reference}"))
.header("cookie", format!("meguri_session={admin_token}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(found["events"].as_array().unwrap().len(), 1);
let event = &found["events"][0];
assert_eq!(event["action"], "login.failed");
assert_eq!(event["details"]["reference"], reference);
assert_eq!(event["details"]["code"], "no_access");
assert_eq!(event["details"]["subject"], "u-4");
assert_eq!(event["details"]["groups"], json!(["/unknown"]));
let denied = h
.client
.get(format!("{base}/api/admin/events"))
.header("cookie", format!("meguri_session={token}"))
.send()
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
h.refused(
&h.attempt(Scenario::new(
json!({"sub":"u-5","preferred_username":"nogroups"}),
))
.await,
"no_access",
);
assert_eq!(users().await, before);
// Everything that can be wrong with a token or callback is refused without a session.
let mut wrong_issuer = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_issuer.issuer = Some("https://evil.example/realms/corp".into());
let mut wrong_audience = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_audience.audience = Some(json!("another-client"));
let mut expired = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
expired.expires_in = -600;
let mut wrong_nonce = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
wrong_nonce.nonce = Some("replayed-nonce".into());
let mut bad_signature = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
bad_signature.corrupt_signature = true;
for scenario in [
wrong_issuer,
wrong_audience,
expired,
wrong_nonce,
bad_signature,
] {
h.refused(&h.attempt(scenario).await, "failed");
}
let mut broken = Scenario::new(claims("u-6", "mallory", &["/payments/team"]));
broken.token_status = 500;
h.refused(&h.attempt(broken).await, "failed");
assert!(
!query!(ExternalIdentityRow,$subject=="u-6".to_owned())
.exists(&iso.store.db)
.await
.unwrap()
);
// Flow protections: state cookie binding, one-time use, missing and foreign state.
let good = || Scenario::new(claims("u-7", "dave", &["/payments/team"]));
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(
&format!("code={code}&state={state}"),
Some("0".repeat(64).as_str()),
)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), None)
.await,
"failed",
);
let (url, state) = h.begin().await;
let code = h.idp.authorize(&url, good());
h.signed_in(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
)
.await;
let code = h.idp.authorize(&url, good());
h.refused(
&h.callback(&format!("code={code}&state={state}"), Some(&state))
.await,
"failed",
);
h.refused(
&h.callback("error=access_denied&state=x", Some("x")).await,
"failed",
);
h.refused(&h.callback("", None).await, "failed");
// A user whose groups were revoked is refused at the next sign-in and loses access at once.
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/search/team"])))
.await;
h.signed_in(&response).await;
let response = h
.attempt(Scenario::new(claims("u-1", "carol", &["/unknown"])))
.await;
h.refused(&response, "no_access");
let row = query!(UserRow,$username=="carol".to_owned())
.get(&iso.store.db)
.await
.unwrap()
.unwrap();
let id = row.id;
assert!(
!query!(meguri::models::WorkspaceMemberRow,$user_id==id)
.exists(&iso.store.db)
.await
.unwrap()
);
drop(h);
server.close().await;
iso.drop().await;
}
fn idp_issuer(h: &Harness) -> String {
h.idp.issuer()
}
trait Count {
async fn objects_count(iso: &IsolatedDatabase) -> usize;
}
impl Count for UserRow {
async fn objects_count(iso: &IsolatedDatabase) -> usize {
use cot::db::Model;
UserRow::objects().all(&iso.store.db).await.unwrap().len()
}
}