//! 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 { 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 { response .headers() .get_all("set-cookie") .iter() .map(|v| v.to_str().unwrap().to_owned()) .collect() } fn cookie_value(response: &Response, name: &str) -> Option { 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() } }