Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
+434
@@ -0,0 +1,434 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user