Files
wehub-resource-sync d718c5a372
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:57 +08:00

841 lines
29 KiB
Rust

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::<Sha256>::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<String, String>, // code -> nonce
jwt_customizer: Option<Box<JwtCustomizer<'a>>>,
token_endpoint_delay: Duration,
discovery_count: usize,
}
type ProviderStateWithLifetime<'a> = ProviderState<'a>;
type SharedProviderState = Arc<Mutex<ProviderStateWithLifetime<'static>>>;
#[derive(Serialize, Deserialize)]
struct DiscoveryResponse {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String,
response_types_supported: Vec<String>,
subject_types_supported: Vec<String>,
id_token_signing_alg_values_supported: Vec<String>,
end_session_endpoint: String,
}
#[derive(Serialize)]
struct JwksResponse {
keys: Vec<serde_json::Value>,
}
#[derive(Serialize)]
struct TokenResponse {
access_token: String,
token_type: String,
id_token: String,
expires_in: i64,
}
async fn discovery_endpoint(state: Data<SharedProviderState>) -> 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<SharedProviderState>) -> 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<SharedProviderState>,
req: web::Form<HashMap<String, String>>,
) -> 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, actix_web::Error>(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<Cookie<'static>> {
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<R>(&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<impl actix_web::body::MessageBody>,
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<Cookie<'static>> = 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<Cookie<'static>> = 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<String>,
) {
let (app, provider) = setup_oidc_test(provider_mutator).await;
let mut cookies: Vec<Cookie<'static>> = 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<impl actix_web::body::MessageBody>,
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<Cookie<'static>> = 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<String, String> = 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<Cookie<'static>> = 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<Cookie<'static>> = 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<Cookie<'static>> = 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"
);
}