use actix_web::{ App, HttpResponse, HttpServer, Responder, cookie::Cookie, http::{StatusCode, header}, test, web::{self, Data}, }; use base64::Engine; use openidconnect::url::Url; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlpage::webserver::http::create_app; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio_util::sync::{CancellationToken, DropGuard}; fn base64url_encode(data: &[u8]) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) } pub fn make_jwt(claims: &serde_json::Value, secret: &str) -> String { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let header = json!({ "alg": "HS256", "typ": "JWT", "kid": "test" }); let header_b64 = base64url_encode(header.to_string().as_bytes()); let payload_b64 = base64url_encode(claims.to_string().as_bytes()); let message = format!("{}.{}", header_b64, payload_b64); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size"); mac.update(message.as_bytes()); let signature = mac.finalize().into_bytes(); let signature_b64 = base64url_encode(&signature); format!("{}.{}.{}", header_b64, payload_b64, signature_b64) } type JwtCustomizer<'a> = dyn Fn(serde_json::Value, &str) -> String + Send + Sync + 'a; struct ProviderState<'a> { secret: String, issuer_url: String, client_id: String, auth_codes: HashMap, // code -> nonce jwt_customizer: Option>>, token_endpoint_delay: Duration, discovery_count: usize, } type ProviderStateWithLifetime<'a> = ProviderState<'a>; type SharedProviderState = Arc>>; #[derive(Serialize, Deserialize)] struct DiscoveryResponse { issuer: String, authorization_endpoint: String, token_endpoint: String, jwks_uri: String, response_types_supported: Vec, subject_types_supported: Vec, id_token_signing_alg_values_supported: Vec, end_session_endpoint: String, } #[derive(Serialize)] struct JwksResponse { keys: Vec, } #[derive(Serialize)] struct TokenResponse { access_token: String, token_type: String, id_token: String, expires_in: i64, } async fn discovery_endpoint(state: Data) -> impl Responder { let mut state = state.lock().unwrap(); state.discovery_count += 1; let discovery = DiscoveryResponse { issuer: state.issuer_url.clone(), authorization_endpoint: format!("{}/auth", state.issuer_url), token_endpoint: format!("{}/token", state.issuer_url), jwks_uri: format!("{}/jwks", state.issuer_url), response_types_supported: vec!["code".to_string()], subject_types_supported: vec!["public".to_string()], id_token_signing_alg_values_supported: vec!["HS256".to_string()], end_session_endpoint: format!("{}/logout", state.issuer_url), }; drop(state); HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "application/json")) .json(discovery) } async fn jwks_endpoint(state: Data) -> impl Responder { let state = state.lock().unwrap(); let jwks = JwksResponse { keys: vec![json!({ "kty": "oct", "kid": "test", "use": "sig", "alg": "HS256", "k": base64url_encode(state.secret.as_bytes()) })], }; HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "application/json")) .json(jwks) } async fn token_endpoint( state: Data, req: web::Form>, ) -> impl Responder { let mut state = state.lock().unwrap(); let Some(code) = req.get("code") else { return HttpResponse::BadRequest().body("Missing code"); }; let nonce = state.auth_codes.get(code).cloned().unwrap_or_default(); if nonce.is_empty() { return HttpResponse::BadRequest().body("Unknown code"); } let now = chrono::Utc::now().timestamp(); let claims = json!({ "iss": state.issuer_url.as_str(), "sub": "test_user", "aud": state.client_id.as_str(), "exp": now + 3600, "iat": now, "nonce": nonce, }); let id_token = state .jwt_customizer .take() .map(|customizer| customizer(claims.clone(), &state.secret)) .unwrap_or_else(|| make_jwt(&claims, &state.secret)); let delay = state.token_endpoint_delay; drop(state); let response = TokenResponse { access_token: "test_access_token".to_string(), token_type: "Bearer".to_string(), id_token, expires_in: 3600, }; let json_bytes = serde_json::to_vec(&response).unwrap(); let body = futures_util::stream::once(async move { tokio::time::sleep(delay).await; Ok::(web::Bytes::from(json_bytes)) }); HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "application/json")) .streaming(body) } pub struct FakeOidcProvider { pub issuer_url: String, pub client_id: String, pub client_secret: String, state: SharedProviderState, _stop_on_drop: DropGuard, } fn extract_set_cookies(headers: &header::HeaderMap) -> Vec> { headers .get_all(header::SET_COOKIE) .filter_map(|h| h.to_str().ok()) .filter_map(|s| Cookie::parse(s).ok()) .map(Cookie::into_owned) .collect() } impl FakeOidcProvider { pub async fn new() -> Self { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let issuer_url = format!("http://127.0.0.1:{}", port); let client_id = "test_client".to_string(); let client_secret = "test_secret".to_string(); let state: SharedProviderState = Arc::new(Mutex::new(ProviderState { secret: client_secret.clone(), issuer_url: issuer_url.clone(), client_id: client_id.clone(), auth_codes: HashMap::new(), jwt_customizer: None, token_endpoint_delay: Duration::ZERO, discovery_count: 0, })); let state_for_server = Arc::clone(&state); let server_stop = CancellationToken::new(); let stop_on_drop = server_stop.clone().drop_guard(); let server = HttpServer::new(move || { let state = Data::new(Arc::clone(&state_for_server)); App::new() .app_data(state.clone()) .route( "/.well-known/openid-configuration", web::get().to(discovery_endpoint), ) .route("/jwks", web::get().to(jwks_endpoint)) .route("/token", web::post().to(token_endpoint)) }) .workers(1) .listen(listener) .unwrap() .shutdown_timeout(1) .shutdown_signal(server_stop.cancelled_owned()) .run(); tokio::spawn(server); Self { issuer_url, client_id, client_secret, state, _stop_on_drop: stop_on_drop, } } fn with_state_mut(&self, f: impl FnOnce(&mut ProviderState) -> R) -> R { let mut state = self.state.lock().unwrap(); f(&mut state) } pub fn set_token_endpoint_delay(&self, delay: Duration) { self.with_state_mut(|s| s.token_endpoint_delay = delay); } pub fn discovery_count(&self) -> usize { self.state.lock().unwrap().discovery_count } pub fn store_auth_code(&self, code: String, nonce: String) { self.with_state_mut(|s| { s.auth_codes.insert(code, nonce); }); } } fn get_query_param(url: &Url, name: &str) -> String { url.query_pairs() .find(|(k, _)| k == name) .unwrap() .1 .to_string() } fn permits_storage_after_proxy_adds_freshness(headers: &header::HeaderMap) -> bool { // Reproduces https://github.com/sqlpage/SQLPage/issues/1341, where an // intermediary added `Cache-Control: max-age=86400` to OIDC 303 responses. // `no-store` must still prevent browser storage when both directives exist. !headers .get_all(header::CACHE_CONTROL) .filter_map(|value| value.to_str().ok()) .flat_map(|value| value.split(',')) .any(|directive| directive.trim().eq_ignore_ascii_case("no-store")) } macro_rules! request_with_cookies { ($app:expr, $req:expr, $cookies:expr) => {{ let mut req = $req; for cookie in $cookies.iter() { req = req.cookie(cookie.clone()); } let resp = test::call_service(&$app, req.to_request()).await; for new_cookie in extract_set_cookies(resp.headers()) { $cookies.retain(|c: &Cookie| c.name() != new_cookie.name()); if !new_cookie.value().is_empty() { $cookies.push(new_cookie); } } resp }}; } async fn setup_oidc_test( provider_mutator: impl FnOnce(&mut ProviderState), ) -> ( impl actix_web::dev::Service< actix_http::Request, Response = actix_web::dev::ServiceResponse, Error = actix_web::Error, >, FakeOidcProvider, ) { use sqlpage::{ AppState, app_config::{AppConfig, test_database_url}, }; crate::common::init_log(); let provider = FakeOidcProvider::new().await; provider.with_state_mut(provider_mutator); let db_url = test_database_url(); let config_json = format!( r#"{{ "database_url": "{db_url}", "max_database_pool_connections": 1, "database_connection_retries": 3, "database_connection_acquire_timeout_seconds": 15, "allow_exec": true, "max_uploaded_file_size": 123456, "listen_on": "127.0.0.1:0", "system_root_ca_certificates": false, "oidc_issuer_url": "{}", "oidc_client_id": "{}", "oidc_client_secret": "{}", "oidc_protected_paths": ["/"], "host": "localhost:1" }}"#, provider.issuer_url, provider.client_id, provider.client_secret ); let config: AppConfig = serde_json::from_str(&config_json).unwrap(); let app_state = AppState::init(&config).await.unwrap(); let app = test::init_service(create_app(Data::new(app_state))).await; (app, provider) } #[actix_web::test] async fn test_oidc_cached_authorization_redirect_cannot_replay_consumed_state() { let (app, provider) = setup_oidc_test(|_| {}).await; let mut cookies: Vec> = Vec::new(); // Reproduces https://github.com/sqlpage/SQLPage/issues/1341: Chrome's // private cache can replay a cached 303 without reapplying Set-Cookie // headers, causing the browser to retry an already-consumed OIDC state. let initial_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); let initial_auth_url = Url::parse( initial_response .headers() .get(header::LOCATION) .unwrap() .to_str() .unwrap(), ) .unwrap(); let cached_authorization_url = permits_storage_after_proxy_adds_freshness(initial_response.headers()) .then_some(initial_auth_url.clone()); let state = get_query_param(&initial_auth_url, "state"); let nonce = get_query_param(&initial_auth_url, "nonce"); let redirect_uri = get_query_param(&initial_auth_url, "redirect_uri"); let callback_path = Url::parse(&redirect_uri).unwrap().path().to_owned(); provider.store_auth_code("first-code".to_string(), nonce.clone()); let callback_uri = format!("{callback_path}?code=first-code&state={state}"); let callback_response = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_response.status(), StatusCode::SEE_OTHER); if let Some(stale_authorization_url) = cached_authorization_url { // A fresh Entra code is returned for the authorization URL cached by // Chrome, but its state is the state that the successful callback just // consumed. Before this fix, SQLPage restarted OIDC here, creating the // observed redirect loop. let stale_state = get_query_param(&stale_authorization_url, "state"); provider.store_auth_code("stale-code".to_string(), nonce); let stale_callback_uri = format!("{callback_path}?code=stale-code&state={stale_state}"); let stale_callback_response = request_with_cookies!( app, test::TestRequest::get().uri(&stale_callback_uri), cookies ); panic!( "a cacheable authorization redirect replays a consumed state; stale callback redirected to {}", stale_callback_response .headers() .get(header::LOCATION) .unwrap() .to_str() .unwrap() ); } // With `no-store`, Chrome re-requests `/` after login and sends its new // auth cookie instead of following the original authorization redirect. let final_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(final_response.status(), StatusCode::OK); } #[actix_web::test] async fn test_oidc_happy_path() { let (app, provider) = setup_oidc_test(|_| {}).await; let mut cookies: Vec> = Vec::new(); let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers().get(header::CACHE_CONTROL).unwrap(), "no-store", "the authorization redirect contains a one-time state and must not be cached" ); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state = get_query_param(&auth_url, "state"); let nonce = get_query_param(&auth_url, "nonce"); let redirect_uri = get_query_param(&auth_url, "redirect_uri"); provider.store_auth_code("test_auth_code".to_string(), nonce); let callback_uri = format!( "{}?code=test_auth_code&state={}", Url::parse(&redirect_uri).unwrap().path(), state ); let callback_resp = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER); assert_eq!( callback_resp.headers().get(header::CACHE_CONTROL).unwrap(), "no-store", "the post-login redirect must not re-enter a cached authorization redirect" ); let final_resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(final_resp.status(), StatusCode::OK); } async fn assert_oidc_login_fails( provider_mutator: impl FnOnce(&mut ProviderState), state_override: Option, ) { let (app, provider) = setup_oidc_test(provider_mutator).await; let mut cookies: Vec> = Vec::new(); let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state = get_query_param(&auth_url, "state"); let nonce = get_query_param(&auth_url, "nonce"); let redirect_uri = get_query_param(&auth_url, "redirect_uri"); provider.store_auth_code("test_auth_code".to_string(), nonce); let callback_state = state_override.unwrap_or(state); let callback_uri = format!( "{}?code=test_auth_code&state={}", Url::parse(&redirect_uri).unwrap().path(), callback_state ); let callback_resp = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER); let location = callback_resp .headers() .get("location") .unwrap() .to_str() .unwrap(); assert!( location.starts_with(&provider.issuer_url), "Expected redirect to OIDC provider, but got {location}" ); let auth_cookie_present = cookies.iter().any(|c| c.name() == "sqlpage_auth"); assert!( !auth_cookie_present, "Authentication cookie should not be set on failure" ); } async fn assert_oidc_callback_fails_with_bad_jwt( mutate_jwt_claims: impl FnMut(&mut serde_json::Value) + Send + Sync + 'static, ) { let mutate_jwt_claims = Mutex::new(mutate_jwt_claims); assert_oidc_login_fails( |state| { state.jwt_customizer = Some(Box::new(move |mut claims, secret| { mutate_jwt_claims.lock().unwrap()(&mut claims); make_jwt(&claims, secret) })); }, None, ) .await; } #[actix_web::test] async fn test_oidc_csrf_state_mismatch_is_rejected() { assert_oidc_login_fails(|_| {}, Some("wrong_state".to_string())).await; } #[actix_web::test] async fn test_oidc_nonce_mismatch_is_rejected() { assert_oidc_callback_fails_with_bad_jwt(|claims| { claims["nonce"] = json!("wrong_nonce"); }) .await; } #[actix_web::test] async fn test_oidc_bad_signature_is_rejected() { assert_oidc_login_fails( |state| { state.jwt_customizer = Some(Box::new(|claims, _| make_jwt(&claims, "wrong_secret"))); }, None, ) .await; } #[actix_web::test] async fn test_oidc_wrong_audience_is_rejected() { assert_oidc_callback_fails_with_bad_jwt(|claims| { claims["aud"] = json!("wrong_client"); }) .await; } #[actix_web::test] async fn test_oidc_wrong_issuer_is_rejected() { assert_oidc_callback_fails_with_bad_jwt(|claims| { claims["iss"] = json!("https://wrong-issuer.com"); }) .await; } #[actix_web::test] async fn test_oidc_expired_token_is_rejected() { assert_oidc_callback_fails_with_bad_jwt(|claims| { let current_exp = claims["exp"].as_i64().unwrap(); claims["exp"] = json!(current_exp - 7200); }) .await; } async fn setup_oidc_test_with_prefix( provider_mutator: impl FnOnce(&mut ProviderState), site_prefix: &str, ) -> ( impl actix_web::dev::Service< actix_http::Request, Response = actix_web::dev::ServiceResponse, Error = actix_web::Error, >, FakeOidcProvider, ) { use sqlpage::{ AppState, app_config::{AppConfig, test_database_url}, }; crate::common::init_log(); let provider = FakeOidcProvider::new().await; provider.with_state_mut(provider_mutator); let db_url = test_database_url(); let config_json = format!( r#"{{ "database_url": "{db_url}", "oidc_issuer_url": "{}", "oidc_client_id": "{}", "oidc_client_secret": "{}", "oidc_protected_paths": ["/"], "site_prefix": "{site_prefix}" }}"#, provider.issuer_url, provider.client_id, provider.client_secret ); let config: AppConfig = serde_json::from_str(&config_json).unwrap(); let app_state = AppState::init(&config).await.unwrap(); let app = test::init_service(create_app(Data::new(app_state))).await; (app, provider) } #[actix_web::test] async fn test_oidc_with_site_prefix() { let (app, _provider) = setup_oidc_test_with_prefix(|_| {}, "/my-app/").await; let mut cookies: Vec> = Vec::new(); // Access the app with the prefix let resp = request_with_cookies!(app, test::TestRequest::get().uri("/my-app/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); // Check if the redirect_uri parameter in the auth URL contains the site prefix let redirect_uri = get_query_param(&auth_url, "redirect_uri"); assert!( redirect_uri.contains("/my-app/sqlpage/oidc_callback"), "Redirect URI should contain site prefix. Got: {}", redirect_uri ); } #[actix_web::test] async fn test_oidc_logout_uses_correct_scheme() { use sqlpage::{ AppState, app_config::{AppConfig, test_database_url}, }; crate::common::init_log(); let provider = FakeOidcProvider::new().await; let db_url = test_database_url(); let config_json = format!( r#"{{ "database_url": "{db_url}", "oidc_issuer_url": "{}", "oidc_client_id": "{}", "oidc_client_secret": "{}", "https_domain": "example.com" }}"#, provider.issuer_url, provider.client_id, provider.client_secret ); let config: AppConfig = serde_json::from_str(&config_json).unwrap(); let app_state = AppState::init(&config).await.unwrap(); let logout_path = app_state .oidc_state .as_ref() .unwrap() .config .create_logout_url("/logged_out", None); let app = test::init_service(create_app(Data::new(app_state))).await; // make sure the logout path includes the configured domain assert!(logout_path.starts_with("/sqlpage/oidc_logout")); let req = test::TestRequest::get().uri(&logout_path).to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); let location = resp.headers().get("location").unwrap().to_str().unwrap(); let location_url = Url::parse(location).unwrap(); assert_eq!(location_url.path(), "/logout"); let params: HashMap = location_url.query_pairs().into_owned().collect(); let post_logout = params.get("post_logout_redirect_uri").unwrap(); assert_eq!(post_logout, "https://example.com/logged_out"); } /// An OIDC provider metadata refresh must not block authenticated requests. /// The refresh should happen in the background while existing requests are /// served using the current (possibly stale) OIDC client. #[actix_web::test] async fn test_slow_discovery_does_not_block_authenticated_requests() { let (app, provider) = setup_oidc_test(|_| {}).await; let mut cookies: Vec> = Vec::new(); // Complete a full login to get auth cookies let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state_param = get_query_param(&auth_url, "state"); let nonce = get_query_param(&auth_url, "nonce"); let redirect_uri = get_query_param(&auth_url, "redirect_uri"); provider.store_auth_code("test_auth_code".to_string(), nonce); let callback_uri = format!( "{}?code=test_auth_code&state={}", Url::parse(&redirect_uri).unwrap().path(), state_param ); let callback_resp = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER); // Advance time so the OIDC snapshot appears stale. // The next request triggers a background refresh. let count_before = provider.discovery_count(); tokio::time::pause(); tokio::time::advance(Duration::from_secs(3601)).await; // Resume real time so the DB pool and background refresh work normally. tokio::time::resume(); // An authenticated request must succeed immediately, even though // it triggers a background refresh. let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::OK); // Let the background refresh task complete. tokio::task::yield_now().await; assert!( provider.discovery_count() > count_before, "OIDC provider metadata was not refreshed" ); } /// A slow OIDC token endpoint must not freeze the server. /// The body-read timeout fires and the request completes with a redirect. #[actix_web::test] async fn test_slow_token_endpoint_does_not_freeze_server() { let (app, provider) = setup_oidc_test(|_| {}).await; let mut cookies: Vec> = Vec::new(); let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state_param = get_query_param(&auth_url, "state"); let nonce = get_query_param(&auth_url, "nonce"); let redirect_uri = get_query_param(&auth_url, "redirect_uri"); provider.store_auth_code("test_auth_code".to_string(), nonce); provider.set_token_endpoint_delay(Duration::from_secs(999)); let callback_uri = format!( "{}?code=test_auth_code&state={}", Url::parse(&redirect_uri).unwrap().path(), state_param ); let handle = tokio::task::spawn_local(async move { let mut req = test::TestRequest::get().uri(&callback_uri); for cookie in cookies.iter() { req = req.cookie(cookie.clone()); } test::call_service(&app, req.to_request()).await }); // Let the TCP round-trip complete so awc reads HTTP headers, // then advance past the body-read timeout. tokio::task::yield_now().await; tokio::time::pause(); tokio::time::advance(Duration::from_secs(60)).await; let resp = tokio::time::timeout(Duration::from_secs(1), handle) .await .expect("OIDC callback hung on a slow token endpoint") .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); } /// A logout URL is bound to the session it was issued for. A logout URL /// generated for one session must NOT clear a different browser's auth cookie /// (forced-logout CSRF), while the legitimate logout of the issuing session /// must keep working. #[actix_web::test] async fn test_oidc_logout_is_session_bound() { use sqlpage::{ AppState, app_config::{AppConfig, test_database_url}, }; crate::common::init_log(); let provider = FakeOidcProvider::new().await; let db_url = test_database_url(); let config_json = format!( r#"{{ "database_url": "{db_url}", "oidc_issuer_url": "{}", "oidc_client_id": "{}", "oidc_client_secret": "{}", "oidc_protected_paths": ["/"], "host": "localhost:1" }}"#, provider.issuer_url, provider.client_id, provider.client_secret ); let config: AppConfig = serde_json::from_str(&config_json).unwrap(); let app_state = AppState::init(&config).await.unwrap(); let oidc_state = app_state.oidc_state.clone().unwrap(); let app = test::init_service(create_app(Data::new(app_state))).await; // Complete a full login as the victim. let mut cookies: Vec> = Vec::new(); let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state = get_query_param(&auth_url, "state"); let nonce = get_query_param(&auth_url, "nonce"); let redirect_uri = get_query_param(&auth_url, "redirect_uri"); provider.store_auth_code("test_auth_code".to_string(), nonce); let callback_uri = format!( "{}?code=test_auth_code&state={}", Url::parse(&redirect_uri).unwrap().path(), state ); let callback_resp = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER); let victim_auth = cookies .iter() .find(|c| c.name() == "sqlpage_auth") .expect("victim should be authenticated") .value() .to_string(); assert!(!victim_auth.is_empty()); // A logout URL bound to a DIFFERENT session must not clear the victim's // cookie when the victim's browser follows it. let foreign_logout_url = oidc_state .config .create_logout_url("/", Some("some-other-sessions-token")); let mut req = test::TestRequest::get().uri(&foreign_logout_url); for cookie in &cookies { req = req.cookie(cookie.clone()); } let resp = test::call_service(&app, req.to_request()).await; assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "a logout URL bound to another session must be rejected" ); let cleared = extract_set_cookies(resp.headers()) .iter() .any(|c| c.name() == "sqlpage_auth" && c.value().is_empty()); assert!( !cleared, "the victim's auth cookie must not be cleared by a foreign logout URL" ); // The victim's own logout URL (bound to the victim's session) must work. let own_logout_url = oidc_state.config.create_logout_url("/", Some(&victim_auth)); let mut req = test::TestRequest::get().uri(&own_logout_url); for cookie in &cookies { req = req.cookie(cookie.clone()); } let resp = test::call_service(&app, req.to_request()).await; assert_eq!( resp.status(), StatusCode::SEE_OTHER, "the user's own logout must keep working" ); let cleared = extract_set_cookies(resp.headers()) .iter() .any(|c| c.name() == "sqlpage_auth" && c.value().is_empty()); assert!( cleared, "the user's own logout must clear their auth cookie" ); }