Skip to content

Commit 0cf2e94

Browse files
authored
Refactor OIDC request handling to streamline callback processing and remove redundant code. Simplify the handling of authenticated user info and improve error logging for OIDC callback parameters. (#1022)
* Refactor OIDC request handling to streamline callback processing and remove redundant code. Simplify the handling of authenticated user info and improve error logging for OIDC callback parameters. * Refactor OIDC callback processing to use ID token directly, enhancing cookie management and nonce handling. Update logging for successful logins and streamline token claims retrieval. * Refactor OIDC state management to use a temporary login flow state cookie, enhancing nonce and redirect handling. Update cookie creation and retrieval methods for improved clarity and maintainability. fixes #1014 * Enhance error handling in login flow state parsing by adding context to JSON deserialization failure. * Fix infinite redirect loop in OIDC login flows when multiple tabs initiate login simultaneously, particularly after inactivity in mobile browsers. * clippy
1 parent 0008f5b commit 0cf2e94

File tree

2 files changed

+73
-78
lines changed

2 files changed

+73
-78
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Fixed handling of NULL values in `sqlpage.link`. They were encoded as the string `'null'` instead of being omitted from the link's parameters.
66
- Enable submenu autoclosing (on click) in the shell. This is not ideal, but this prevents a bug introduced in v0.36.0 where the page would scroll back to the top when clicking anywhere on the page after navigating from a submenu. The next version will fix this properly. See https://github.com/sqlpage/SQLPage/issues/1011
77
- Adopt the new nice visual errors introduced in v0.37.1 for "403 Forbidden" and "429 Too Many Requests" errors.
8+
- Fix a bug in oidc login flows. When two tabs in the same browser initiated a login at the same time, an infinite redirect loop could be triggered. This mainly occured when restoring open tabs after a period of inactivity, often in mobile browsers.
89

910
## v0.37.0
1011
- We now cryptographically sign the Windows app during releases, which proves the file hasn’t been tampered with. Once the production certificate is active, Windows will show a "verified publisher" and should stop showing screens saying "This app might harm your device", "Windows protected your PC" or "Are you sure you want to run this application ?".

src/webserver/oidc.rs

Lines changed: 72 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
4141
const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth";
4242
const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback";
4343
const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce";
44-
const SQLPAGE_REDIRECT_URL_COOKIE_PREFIX: &str = "sqlpage_oidc_redirect_url_";
44+
const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_";
4545
const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
4646
const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
4747
const AUTH_COOKIE_EXPIRATION: awc::cookie::time::Duration =
4848
actix_web::cookie::time::Duration::days(7);
49+
const LOGIN_FLOW_STATE_COOKIE_EXPIRATION: awc::cookie::time::Duration =
50+
actix_web::cookie::time::Duration::minutes(10);
4951

5052
#[derive(Clone, Debug, Serialize, Deserialize)]
5153
#[serde(transparent)]
@@ -329,15 +331,17 @@ enum MiddlewareResponse {
329331
async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> MiddlewareResponse {
330332
log::trace!("Started OIDC middleware request handling");
331333
oidc_state.refresh_if_expired(&request).await;
334+
335+
if request.path() == SQLPAGE_REDIRECT_URI {
336+
let response = handle_oidc_callback(oidc_state, request).await;
337+
return MiddlewareResponse::Respond(response);
338+
}
339+
332340
match get_authenticated_user_info(oidc_state, &request).await {
333341
Ok(Some(claims)) => {
334-
if request.path() == SQLPAGE_REDIRECT_URI {
335-
MiddlewareResponse::Respond(handle_authenticated_oidc_callback(request))
336-
} else {
337-
log::trace!("Storing authenticated user info in request extensions: {claims:?}");
338-
request.extensions_mut().insert(claims);
339-
MiddlewareResponse::Forward(request)
340-
}
342+
log::trace!("Storing authenticated user info in request extensions: {claims:?}");
343+
request.extensions_mut().insert(claims);
344+
MiddlewareResponse::Forward(request)
341345
}
342346
Ok(None) => {
343347
log::trace!("No authenticated user found");
@@ -356,11 +360,6 @@ async fn handle_unauthenticated_request(
356360
request: ServiceRequest,
357361
) -> MiddlewareResponse {
358362
log::debug!("Handling unauthenticated request to {}", request.path());
359-
if request.path() == SQLPAGE_REDIRECT_URI {
360-
log::debug!("The request is the OIDC callback");
361-
let response = handle_oidc_callback(oidc_state, request).await;
362-
return MiddlewareResponse::Respond(response);
363-
}
364363

365364
if oidc_state.config.is_public_path(request.path()) {
366365
return MiddlewareResponse::Forward(request);
@@ -374,8 +373,7 @@ async fn handle_unauthenticated_request(
374373
}
375374

376375
async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -> ServiceResponse {
377-
let query_string = request.query_string();
378-
match process_oidc_callback(oidc_state, query_string, &request).await {
376+
match process_oidc_callback(oidc_state, &request).await {
379377
Ok(response) => request.into_response(response),
380378
Err(e) => {
381379
log::error!("Failed to process OIDC callback. Refreshing oidc provider metadata, then redirecting to home page: {e:#}");
@@ -386,24 +384,6 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -
386384
}
387385
}
388386

389-
/// When an user has already authenticated (potentially in another tab), we ignore the callback and redirect to the initial URL.
390-
fn handle_authenticated_oidc_callback(request: ServiceRequest) -> ServiceResponse {
391-
// Try to get redirect URL from query params if available
392-
let redirect_url = Query::<OidcCallbackParams>::from_query(request.query_string())
393-
.with_context(|| "Failed to parse OIDC callback parameters in authenticated callback")
394-
.and_then(|params| get_redirect_url_cookie(&request, &params.state))
395-
.map_or_else(
396-
|e| {
397-
log::warn!("No redirect URL cookie: {e:#}");
398-
"/".to_string()
399-
},
400-
|cookie| cookie.value().to_string(),
401-
);
402-
403-
log::debug!("OIDC callback received for authenticated user. Redirecting to {redirect_url}");
404-
request.into_response(build_redirect_response(redirect_url))
405-
}
406-
407387
impl<S> Service<ServiceRequest> for OidcService<S>
408388
where
409389
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error> + 'static,
@@ -429,64 +409,59 @@ where
429409

430410
async fn process_oidc_callback(
431411
oidc_state: &OidcState,
432-
query_string: &str,
433412
request: &ServiceRequest,
434413
) -> anyhow::Result<HttpResponse> {
435-
let http_client = get_http_client_from_appdata(request)?;
436-
437-
let params = Query::<OidcCallbackParams>::from_query(query_string)
438-
.with_context(|| {
439-
format!(
440-
"{SQLPAGE_REDIRECT_URI}: failed to parse OIDC callback parameters from {query_string}"
441-
)
442-
})?
414+
let params = Query::<OidcCallbackParams>::from_query(request.query_string())
415+
.with_context(|| format!("{SQLPAGE_REDIRECT_URI}: invalid url parameters"))?
443416
.into_inner();
444-
445-
let mut redirect_url_cookie = get_redirect_url_cookie(request, &params.state)
446-
.with_context(|| "Failed to read redirect URL from cookie")?;
447-
448-
let client = oidc_state.get_client().await;
449417
log::debug!("Processing OIDC callback with params: {params:?}. Requesting token...");
450-
let token_response = exchange_code_for_token(&client, http_client, params.clone()).await?;
451-
log::debug!("Received token response: {token_response:?}");
418+
let mut tmp_login_flow_state_cookie = get_tmp_login_flow_state_cookie(request, &params.state)?;
419+
let client = oidc_state.get_client().await;
420+
let http_client = get_http_client_from_appdata(request)?;
421+
let id_token = exchange_code_for_token(&client, http_client, params.clone()).await?;
422+
log::debug!("Received token response: {id_token:?}");
423+
let LoginFlowState {
424+
nonce,
425+
redirect_target,
426+
} = parse_login_flow_state(&tmp_login_flow_state_cookie)?;
427+
let redirect_target = validate_redirect_url(redirect_target.to_string());
452428

453-
let redirect_target = validate_redirect_url(redirect_url_cookie.value().to_string());
454429
log::info!("Redirecting to {redirect_target} after a successful login");
455430
let mut response = build_redirect_response(redirect_target);
456-
set_auth_cookie(&mut response, &token_response).context("Failed to set auth cookie")?;
457-
458-
// Clean up the state-specific cookie after successful authentication
459-
redirect_url_cookie.set_path("/");
460-
response.add_removal_cookie(&redirect_url_cookie)?;
461-
431+
set_auth_cookie(&mut response, &id_token);
432+
let claims = oidc_state
433+
.get_token_claims(id_token, &nonce)
434+
.await
435+
.context("The identity provider returned an invalid ID token")?;
436+
log::debug!("{} successfully logged in", claims.subject().as_str());
437+
let nonce_cookie = create_final_nonce_cookie(&nonce);
438+
response.add_cookie(&nonce_cookie)?;
439+
tmp_login_flow_state_cookie.set_path("/"); // Required to clean up the cookie
440+
response.add_removal_cookie(&tmp_login_flow_state_cookie)?;
462441
Ok(response)
463442
}
464443

465444
async fn exchange_code_for_token(
466445
oidc_client: &OidcClient,
467446
http_client: &awc::Client,
468447
oidc_callback_params: OidcCallbackParams,
469-
) -> anyhow::Result<OidcTokenResponse> {
448+
) -> anyhow::Result<OidcToken> {
470449
let token_response = oidc_client
471450
.exchange_code(openidconnect::AuthorizationCode::new(
472451
oidc_callback_params.code,
473452
))?
474453
.request_async(&AwcHttpClient::from_client(http_client))
475454
.await
476455
.context("Failed to exchange code for token")?;
477-
Ok(token_response)
478-
}
479-
480-
fn set_auth_cookie(
481-
response: &mut HttpResponse,
482-
token_response: &OidcTokenResponse,
483-
) -> anyhow::Result<()> {
484456
let access_token = token_response.access_token();
485457
log::trace!("Received access token: {}", access_token.secret());
486458
let id_token = token_response
487459
.id_token()
488460
.context("No ID token found in the token response. You may have specified an oauth2 provider that does not support OIDC.")?;
461+
Ok(id_token.clone())
462+
}
489463

464+
fn set_auth_cookie(response: &mut HttpResponse, id_token: &OidcToken) {
490465
let id_token_str = id_token.to_string();
491466
log::trace!("Setting auth cookie: {SQLPAGE_AUTH_COOKIE_NAME}=\"{id_token_str}\"");
492467
let id_token_size_kb = id_token_str.len() / 1024;
@@ -505,20 +480,17 @@ fn set_auth_cookie(
505480
.finish();
506481

507482
response.add_cookie(&cookie).unwrap();
508-
Ok(())
509483
}
510484

511485
async fn build_auth_provider_redirect_response(
512486
oidc_state: &OidcState,
513487
initial_url: &str,
514488
) -> HttpResponse {
515489
let AuthUrl { url, params } = build_auth_url(oidc_state).await;
516-
let nonce_cookie = create_nonce_cookie(&params.nonce);
517-
let redirect_cookie = create_redirect_cookie(&params.csrf_token, initial_url);
490+
let tmp_login_flow_state_cookie = create_tmp_login_flow_state_cookie(&params, initial_url);
518491
HttpResponse::TemporaryRedirect()
519492
.append_header((header::LOCATION, url.to_string()))
520-
.cookie(nonce_cookie)
521-
.cookie(redirect_cookie)
493+
.cookie(tmp_login_flow_state_cookie)
522494
.body("Redirecting...")
523495
}
524496

@@ -540,7 +512,7 @@ async fn get_authenticated_user_info(
540512
let id_token = OidcToken::from_str(&cookie_value)
541513
.with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?;
542514

543-
let nonce = get_nonce_from_cookie(request)?;
515+
let nonce = get_final_nonce_from_cookie(request)?;
544516
log::debug!("Verifying id token: {id_token:?}");
545517
let claims = oidc_state.get_token_claims(id_token, &nonce).await?;
546518
log::debug!("The current user is: {claims:?}");
@@ -772,7 +744,7 @@ fn nonce_matches(id_token_nonce: &Nonce, state_nonce: &Nonce) -> Result<(), Stri
772744
Ok(())
773745
}
774746

775-
fn create_nonce_cookie(nonce: &Nonce) -> Cookie<'_> {
747+
fn create_final_nonce_cookie(nonce: &Nonce) -> Cookie<'_> {
776748
Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, nonce.secret())
777749
.secure(true)
778750
.http_only(true)
@@ -782,34 +754,56 @@ fn create_nonce_cookie(nonce: &Nonce) -> Cookie<'_> {
782754
.finish()
783755
}
784756

785-
fn create_redirect_cookie<'a>(csrf_token: &CsrfToken, initial_url: &'a str) -> Cookie<'a> {
786-
let cookie_name = SQLPAGE_REDIRECT_URL_COOKIE_PREFIX.to_owned() + csrf_token.secret();
787-
Cookie::build(cookie_name, initial_url)
757+
fn create_tmp_login_flow_state_cookie<'a>(
758+
params: &'a AuthUrlParams,
759+
initial_url: &'a str,
760+
) -> Cookie<'a> {
761+
let csrf_token = &params.csrf_token;
762+
let cookie_name = SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret();
763+
let cookie_value = serde_json::to_string(&LoginFlowState {
764+
nonce: params.nonce.clone(),
765+
redirect_target: initial_url,
766+
})
767+
.expect("login flow state is always serializable");
768+
Cookie::build(cookie_name, cookie_value)
788769
.secure(true)
789770
.http_only(true)
790771
.same_site(actix_web::cookie::SameSite::Lax)
791772
.path("/")
792-
.max_age(actix_web::cookie::time::Duration::minutes(10))
773+
.max_age(LOGIN_FLOW_STATE_COOKIE_EXPIRATION)
793774
.finish()
794775
}
795776

796-
fn get_nonce_from_cookie(request: &ServiceRequest) -> anyhow::Result<Nonce> {
777+
fn get_final_nonce_from_cookie(request: &ServiceRequest) -> anyhow::Result<Nonce> {
797778
let cookie = request
798779
.cookie(SQLPAGE_NONCE_COOKIE_NAME)
799780
.with_context(|| format!("No {SQLPAGE_NONCE_COOKIE_NAME} cookie found"))?;
800781
Ok(Nonce::new(cookie.value().to_string()))
801782
}
802783

803-
fn get_redirect_url_cookie(
784+
fn get_tmp_login_flow_state_cookie(
804785
request: &ServiceRequest,
805786
csrf_token: &CsrfToken,
806787
) -> anyhow::Result<Cookie<'static>> {
807-
let cookie_name = SQLPAGE_REDIRECT_URL_COOKIE_PREFIX.to_owned() + csrf_token.secret();
788+
let cookie_name = SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret();
808789
request
809790
.cookie(&cookie_name)
810791
.with_context(|| format!("No {cookie_name} cookie found"))
811792
}
812793

794+
#[derive(Debug, Serialize, Deserialize, Clone)]
795+
struct LoginFlowState<'a> {
796+
#[serde(rename = "n")]
797+
nonce: Nonce,
798+
#[serde(rename = "r")]
799+
redirect_target: &'a str,
800+
}
801+
802+
fn parse_login_flow_state<'a>(cookie: &'a Cookie<'_>) -> anyhow::Result<LoginFlowState<'a>> {
803+
serde_json::from_str(cookie.value())
804+
.with_context(|| format!("Invalid login flow state cookie: {}", cookie.value()))
805+
}
806+
813807
/// Given an audience, verify if it is trusted. The `client_id` is always trusted, independently of this function.
814808
#[derive(Clone, Debug)]
815809
pub struct AudienceVerifier(Option<HashSet<String>>);

0 commit comments

Comments
 (0)