434 lines
15 KiB
Rust
434 lines
15 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);
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
}
|