Files
meguri/tests/oidc.rs
T

434 lines
15 KiB
Rust
Raw Normal View History

//! 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);
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()
}
}