Skip to content

Commit 817674c

Browse files
lovasoaclaude
andcommitted
OIDC: non-blocking background refresh and body read timeout
- OIDC provider metadata refreshes now run in a background task via spawn_local, never blocking incoming HTTP requests. - Multiple concurrent refresh triggers are deduplicated via an AtomicBool. - The write lock on the OIDC client is only held briefly to swap data, not during the upstream HTTP call. - Add a body-read timeout to OIDC HTTP requests to prevent hangs when the provider stalls after sending headers. - Add tests for both scenarios: slow discovery and slow token endpoint. Fixes #1231 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 431ab87 commit 817674c

2 files changed

Lines changed: 128 additions & 48 deletions

File tree

src/webserver/oidc.rs

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ pub struct ClientWithTime {
198198
pub struct OidcState {
199199
pub config: OidcConfig,
200200
client: RwLock<ClientWithTime>,
201+
refreshing: std::sync::atomic::AtomicBool,
201202
}
202203

203204
impl OidcState {
@@ -212,36 +213,51 @@ impl OidcState {
212213
end_session_endpoint,
213214
last_update: Instant::now(),
214215
}),
216+
refreshing: std::sync::atomic::AtomicBool::new(false),
215217
})
216218
}
217219

218-
async fn refresh(&self, service_request: &ServiceRequest) {
219-
let mut write_guard = self.client.write().await;
220-
match build_oidc_client_from_appdata(&self.config, service_request).await {
221-
Ok((http_client, end_session_endpoint)) => {
222-
*write_guard = ClientWithTime {
223-
client: http_client,
224-
end_session_endpoint,
225-
last_update: Instant::now(),
220+
/// Spawns a background task to refresh the OIDC client from the provider
221+
/// metadata URL if it hasn't been refreshed within `max_age`.
222+
/// Returns immediately without blocking the caller.
223+
/// Multiple concurrent calls are deduplicated via an atomic flag.
224+
fn refresh_in_background(self: &Arc<Self>, http_client: &Client, max_age: Duration) {
225+
use std::sync::atomic::Ordering;
226+
let Ok(last_update) = self.client.try_read().map(|g| g.last_update) else {
227+
return; // write lock held → a refresh is already in progress
228+
};
229+
if last_update.elapsed() <= max_age {
230+
return;
231+
}
232+
if self.refreshing.swap(true, Ordering::AcqRel) {
233+
return; // another refresh is already running
234+
}
235+
let state = Arc::clone(self);
236+
let http_client = http_client.clone();
237+
tokio::task::spawn_local(async move {
238+
match build_oidc_client(&state.config, &http_client).await {
239+
Ok((client, end_session_endpoint)) => {
240+
*state.client.write().await = ClientWithTime {
241+
client,
242+
end_session_endpoint,
243+
last_update: Instant::now(),
244+
};
226245
}
246+
Err(e) => log::error!("Failed to refresh OIDC client: {e:#}"),
227247
}
228-
Err(e) => log::error!("Failed to refresh OIDC client: {e:#}"),
229-
}
248+
state.refreshing.store(false, Ordering::Release);
249+
});
230250
}
231251

232252
/// Refreshes the OIDC client from the provider metadata URL if it has expired.
233253
/// Most providers update their signing keys periodically.
234-
pub async fn refresh_if_expired(&self, service_request: &ServiceRequest) {
235-
if self.client.read().await.last_update.elapsed() > OIDC_CLIENT_MAX_REFRESH_INTERVAL {
236-
self.refresh(service_request).await;
237-
}
254+
pub fn refresh_if_expired(self: &Arc<Self>, http_client: &Client) {
255+
self.refresh_in_background(http_client, OIDC_CLIENT_MAX_REFRESH_INTERVAL);
238256
}
239257

240258
/// When an authentication error is encountered, refresh the OIDC client info faster
241-
pub async fn refresh_on_error(&self, service_request: &ServiceRequest) {
242-
if self.client.read().await.last_update.elapsed() > OIDC_CLIENT_MIN_REFRESH_INTERVAL {
243-
self.refresh(service_request).await;
244-
}
259+
pub fn refresh_on_error(self: &Arc<Self>, http_client: &Client) {
260+
self.refresh_in_background(http_client, OIDC_CLIENT_MIN_REFRESH_INTERVAL);
245261
}
246262

247263
/// Gets a reference to the oidc client, potentially generating a new one if needed
@@ -252,6 +268,15 @@ impl OidcState {
252268
)
253269
}
254270

271+
/// Forces the OIDC client to appear stale so that the next request triggers a refresh.
272+
#[doc(hidden)]
273+
pub async fn force_expire(&self) {
274+
self.client.write().await.last_update =
275+
Instant::now()
276+
.checked_sub(OIDC_CLIENT_MAX_REFRESH_INTERVAL + Duration::from_secs(1))
277+
.unwrap_or(Instant::now());
278+
}
279+
255280
pub async fn get_end_session_endpoint(&self) -> Option<EndSessionUrl> {
256281
self.client.read().await.end_session_endpoint.clone()
257282
}
@@ -309,14 +334,6 @@ pub async fn initialize_oidc_state(
309334
)))
310335
}
311336

312-
async fn build_oidc_client_from_appdata(
313-
cfg: &OidcConfig,
314-
req: &ServiceRequest,
315-
) -> anyhow::Result<(OidcClient, Option<EndSessionUrl>)> {
316-
let http_client = get_http_client_from_appdata(req)?;
317-
build_oidc_client(cfg, http_client).await
318-
}
319-
320337
async fn build_oidc_client(
321338
oidc_cfg: &OidcConfig,
322339
http_client: &Client,
@@ -405,9 +422,14 @@ enum MiddlewareResponse {
405422
Respond(ServiceResponse),
406423
}
407424

408-
async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> MiddlewareResponse {
425+
async fn handle_request(
426+
oidc_state: &Arc<OidcState>,
427+
request: ServiceRequest,
428+
) -> MiddlewareResponse {
409429
log::trace!("Started OIDC middleware request handling");
410-
oidc_state.refresh_if_expired(&request).await;
430+
if let Ok(http_client) = get_http_client_from_appdata(&request) {
431+
oidc_state.refresh_if_expired(http_client);
432+
}
411433

412434
if request.path() == oidc_state.config.redirect_uri {
413435
let response = handle_oidc_callback(oidc_state, request).await;
@@ -431,7 +453,9 @@ async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> Midd
431453
}
432454
Err(e) => {
433455
log::debug!("An auth cookie is present but could not be verified. Redirecting to OIDC provider to re-authenticate. {e:?}");
434-
oidc_state.refresh_on_error(&request).await;
456+
if let Ok(http_client) = get_http_client_from_appdata(&request) {
457+
oidc_state.refresh_on_error(http_client);
458+
}
435459
handle_unauthenticated_request(oidc_state, request).await
436460
}
437461
}
@@ -456,7 +480,10 @@ async fn handle_unauthenticated_request(
456480
MiddlewareResponse::Respond(request.into_response(response))
457481
}
458482

459-
async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -> ServiceResponse {
483+
async fn handle_oidc_callback(
484+
oidc_state: &Arc<OidcState>,
485+
request: ServiceRequest,
486+
) -> ServiceResponse {
460487
match process_oidc_callback(oidc_state, &request).await {
461488
Ok(mut response) => {
462489
clear_redirect_count_cookie(&mut response);
@@ -467,7 +494,7 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -
467494
}
468495

469496
async fn handle_oidc_callback_error(
470-
oidc_state: &OidcState,
497+
oidc_state: &Arc<OidcState>,
471498
request: ServiceRequest,
472499
e: anyhow::Error,
473500
) -> ServiceResponse {
@@ -478,7 +505,9 @@ async fn handle_oidc_callback_error(
478505
log::error!(
479506
"Failed to process OIDC callback (attempt {redirect_count}). Refreshing oidc provider metadata, then redirecting to home page: {e:#}"
480507
);
481-
oidc_state.refresh_on_error(&request).await;
508+
if let Ok(http_client) = get_http_client_from_appdata(&request) {
509+
oidc_state.refresh_on_error(http_client);
510+
}
482511
let resp = build_auth_provider_redirect_response(oidc_state, "/", redirect_count).await;
483512
request.into_response(resp)
484513
}

tests/oidc/mod.rs

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use actix_web::{
22
cookie::Cookie,
3+
dev::Service,
34
http::{header, StatusCode},
45
test,
56
web::{self, Data},
@@ -83,17 +84,21 @@ struct TokenResponse {
8384
}
8485

8586
async fn discovery_endpoint(state: Data<SharedProviderState>) -> impl Responder {
86-
let state = state.lock().unwrap();
87-
let discovery = DiscoveryResponse {
88-
issuer: state.issuer_url.clone(),
89-
authorization_endpoint: format!("{}/auth", state.issuer_url),
90-
token_endpoint: format!("{}/token", state.issuer_url),
91-
jwks_uri: format!("{}/jwks", state.issuer_url),
92-
response_types_supported: vec!["code".to_string()],
93-
subject_types_supported: vec!["public".to_string()],
94-
id_token_signing_alg_values_supported: vec!["HS256".to_string()],
95-
end_session_endpoint: format!("{}/logout", state.issuer_url),
87+
let (discovery, delay) = {
88+
let state = state.lock().unwrap();
89+
let discovery = DiscoveryResponse {
90+
issuer: state.issuer_url.clone(),
91+
authorization_endpoint: format!("{}/auth", state.issuer_url),
92+
token_endpoint: format!("{}/token", state.issuer_url),
93+
jwks_uri: format!("{}/jwks", state.issuer_url),
94+
response_types_supported: vec!["code".to_string()],
95+
subject_types_supported: vec!["public".to_string()],
96+
id_token_signing_alg_values_supported: vec!["HS256".to_string()],
97+
end_session_endpoint: format!("{}/logout", state.issuer_url),
98+
};
99+
(discovery, state.discovery_delay)
96100
};
101+
tokio::time::sleep(delay).await;
97102
HttpResponse::Ok()
98103
.insert_header((header::CONTENT_TYPE, "application/json"))
99104
.json(discovery)
@@ -282,6 +287,7 @@ async fn setup_oidc_test(
282287
Error = actix_web::Error,
283288
>,
284289
FakeOidcProvider,
290+
Data<sqlpage::AppState>,
285291
) {
286292
use sqlpage::{
287293
app_config::{test_database_url, AppConfig},
@@ -313,13 +319,14 @@ async fn setup_oidc_test(
313319

314320
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
315321
let app_state = AppState::init(&config).await.unwrap();
316-
let app = test::init_service(create_app(Data::new(app_state))).await;
317-
(app, provider)
322+
let app_data = Data::new(app_state);
323+
let app = test::init_service(create_app(app_data.clone())).await;
324+
(app, provider, app_data)
318325
}
319326

320327
#[actix_web::test]
321328
async fn test_oidc_happy_path() {
322-
let (app, provider) = setup_oidc_test(|_| {}).await;
329+
let (app, provider, _) = setup_oidc_test(|_| {}).await;
323330
let mut cookies: Vec<Cookie<'static>> = Vec::new();
324331

325332
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
@@ -348,7 +355,7 @@ async fn assert_oidc_login_fails(
348355
provider_mutator: impl FnOnce(&mut ProviderState),
349356
state_override: Option<String>,
350357
) {
351-
let (app, provider) = setup_oidc_test(provider_mutator).await;
358+
let (app, provider, _) = setup_oidc_test(provider_mutator).await;
352359
let mut cookies: Vec<Cookie<'static>> = Vec::new();
353360

354361
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
@@ -556,8 +563,52 @@ async fn test_oidc_logout_uses_correct_scheme() {
556563
assert_eq!(post_logout, "https://example.com/logged_out");
557564
}
558565

559-
/// A slow OIDC provider must not freeze the server.
560-
/// See https://github.com/sqlpage/SQLPage/issues/1231
566+
/// An OIDC provider metadata refresh must not block authenticated requests.
567+
/// The refresh should happen in the background while existing requests are
568+
/// served using the current (possibly stale) OIDC client.
569+
#[actix_web::test]
570+
async fn test_slow_discovery_does_not_block_authenticated_requests() {
571+
let (app, provider) = setup_oidc_test(|_| {}).await;
572+
let mut cookies: Vec<Cookie<'static>> = Vec::new();
573+
574+
// Complete a full login to get auth cookies
575+
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
576+
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
577+
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
578+
let state_param = get_query_param(&auth_url, "state");
579+
let nonce = get_query_param(&auth_url, "nonce");
580+
let redirect_uri = get_query_param(&auth_url, "redirect_uri");
581+
provider.store_auth_code("test_auth_code".to_string(), nonce);
582+
let callback_uri = format!(
583+
"{}?code=test_auth_code&state={}",
584+
Url::parse(&redirect_uri).unwrap().path(),
585+
state_param
586+
);
587+
let callback_resp =
588+
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
589+
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
590+
591+
// Advance time so the OIDC snapshot appears stale.
592+
// The next request triggers a background refresh.
593+
let count_before = provider.discovery_count();
594+
tokio::time::pause();
595+
tokio::time::advance(Duration::from_secs(3601)).await;
596+
597+
// An authenticated request must succeed immediately, even though
598+
// it triggers a background refresh.
599+
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
600+
assert_eq!(resp.status(), StatusCode::OK);
601+
602+
// Let the background refresh task complete.
603+
tokio::task::yield_now().await;
604+
assert!(
605+
provider.discovery_count() > count_before,
606+
"OIDC provider metadata was not refreshed"
607+
);
608+
}
609+
610+
/// A slow OIDC token endpoint must not freeze the server.
611+
/// The body-read timeout fires and the request completes with a redirect.
561612
#[actix_web::test]
562613
async fn test_slow_token_endpoint_does_not_freeze_server() {
563614
let (app, provider) = setup_oidc_test(|_| {}).await;

0 commit comments

Comments
 (0)