Skip to content

Commit b6f81b9

Browse files
cursoragentlovasoa
andcommitted
Refactor OIDC state handling to use separate nonce and redirect cookies
Co-authored-by: contact <contact@ophir.dev>
1 parent 31a4c29 commit b6f81b9

File tree

1 file changed

+128
-25
lines changed

1 file changed

+128
-25
lines changed

src/webserver/oidc.rs

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
4040

4141
const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth";
4242
const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback";
43-
const SQLPAGE_STATE_COOKIE_NAME: &str = "sqlpage_oidc_state";
43+
const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce";
44+
const SQLPAGE_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_";
4445
const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
4546
const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
4647

@@ -375,8 +376,17 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -
375376
match process_oidc_callback(oidc_state, query_string, &request).await {
376377
Ok(response) => request.into_response(response),
377378
Err(e) => {
379+
// Try to get redirect URL from query params if available
378380
let redirect_url =
379-
get_state_from_cookie(&request).map_or_else(|_| "/".into(), |s| s.initial_url);
381+
if let Ok(params) = Query::<OidcCallbackParams>::from_query(query_string) {
382+
if let Ok(state) = get_state_from_cookies(&request, &params.state) {
383+
state.initial_url
384+
} else {
385+
"/".to_string()
386+
}
387+
} else {
388+
"/".to_string()
389+
};
380390
log::error!("Failed to process OIDC callback. Refreshing oidc provider metadata, then redirecting to {redirect_url}: {e:#}");
381391
oidc_state.refresh_on_error(&request).await;
382392
let resp = build_auth_provider_redirect_response(oidc_state, redirect_url).await;
@@ -387,10 +397,17 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -
387397

388398
/// When an user has already authenticated (potentially in another tab), we ignore the callback and redirect to the initial URL.
389399
fn handle_authenticated_oidc_callback(request: ServiceRequest) -> ServiceResponse {
390-
let redirect_url = match get_state_from_cookie(&request) {
391-
Ok(state) => state.initial_url,
392-
Err(_) => "/".to_string(),
393-
};
400+
// Try to get redirect URL from query params if available
401+
let redirect_url =
402+
if let Ok(params) = Query::<OidcCallbackParams>::from_query(request.query_string()) {
403+
if let Ok(state) = get_state_from_cookies(&request, &params.state) {
404+
state.initial_url
405+
} else {
406+
"/".to_string()
407+
}
408+
} else {
409+
"/".to_string()
410+
};
394411
log::debug!("OIDC callback received for authenticated user. Redirecting to {redirect_url}");
395412
request.into_response(build_redirect_response(redirect_url))
396413
}
@@ -425,8 +442,6 @@ async fn process_oidc_callback(
425442
) -> anyhow::Result<HttpResponse> {
426443
let http_client = get_http_client_from_appdata(request)?;
427444

428-
let state = get_state_from_cookie(request).context("Failed to read oidc state cookie")?;
429-
430445
let params = Query::<OidcCallbackParams>::from_query(query_string)
431446
.with_context(|| {
432447
format!(
@@ -435,10 +450,8 @@ async fn process_oidc_callback(
435450
})?
436451
.into_inner();
437452

438-
if state.csrf_token.secret() != params.state.secret() {
439-
log::debug!("CSRF token mismatch: expected {state:?}, got {params:?}");
440-
return Err(anyhow!("Invalid CSRF token: {}", params.state.secret()));
441-
}
453+
let state = get_state_from_cookies(request, &params.state)
454+
.context("Failed to read oidc state cookies")?;
442455

443456
let client = oidc_state.get_client().await;
444457
log::debug!("Processing OIDC callback with params: {params:?}. Requesting token...");
@@ -451,6 +464,22 @@ async fn process_oidc_callback(
451464
set_auth_cookie(&mut response, &token_response, oidc_state)
452465
.await
453466
.context("Failed to set auth cookie")?;
467+
468+
// Clean up the state-specific cookie after successful authentication
469+
let state_cookie_name = format!(
470+
"{}{}",
471+
SQLPAGE_STATE_COOKIE_PREFIX,
472+
state.csrf_token.secret()
473+
);
474+
let cleanup_cookie = Cookie::build(state_cookie_name, "")
475+
.secure(true)
476+
.http_only(true)
477+
.same_site(actix_web::cookie::SameSite::Lax)
478+
.path("/")
479+
.max_age(actix_web::cookie::time::Duration::seconds(0)) // Expire immediately
480+
.finish();
481+
response.add_cookie(&cleanup_cookie).unwrap();
482+
454483
Ok(response)
455484
}
456485

@@ -511,10 +540,11 @@ async fn build_auth_provider_redirect_response(
511540
) -> HttpResponse {
512541
let AuthUrl { url, params } = build_auth_url(oidc_state).await;
513542
let state = OidcLoginState::new(initial_url, params);
514-
let state_cookie = create_state_cookie(&state);
543+
let (nonce_cookie, redirect_cookie) = create_state_cookies(&state);
515544
HttpResponse::TemporaryRedirect()
516545
.append_header(("Location", url.to_string()))
517-
.cookie(state_cookie)
546+
.cookie(nonce_cookie)
547+
.cookie(redirect_cookie)
518548
.body("Redirecting...")
519549
}
520550

@@ -536,9 +566,21 @@ async fn get_authenticated_user_info(
536566
let id_token = OidcToken::from_str(&cookie_value)
537567
.with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?;
538568

539-
let state = get_state_from_cookie(request)?;
569+
// Try to get state from cookies if this is a callback request
570+
let state = if request.path() == SQLPAGE_REDIRECT_URI {
571+
if let Ok(params) = Query::<OidcCallbackParams>::from_query(request.query_string()) {
572+
get_state_from_cookies(request, &params.state).ok()
573+
} else {
574+
None
575+
}
576+
} else {
577+
None
578+
};
579+
540580
log::debug!("Verifying id token: {id_token:?}");
541-
let claims = oidc_state.get_token_claims(id_token, Some(&state)).await?;
581+
let claims = oidc_state
582+
.get_token_claims(id_token, state.as_ref())
583+
.await?;
542584
log::debug!("The current user is: {claims:?}");
543585
Ok(Some(claims))
544586
}
@@ -788,6 +830,21 @@ struct OidcLoginState {
788830
nonce: Nonce,
789831
}
790832

833+
#[derive(Debug, Serialize, Deserialize)]
834+
struct OidcNonceState {
835+
/// The source nonce to use for the login process. It must be checked against the hash
836+
/// stored in the ID token.
837+
#[serde(rename = "n")]
838+
nonce: Nonce,
839+
}
840+
841+
#[derive(Debug, Serialize, Deserialize)]
842+
struct OidcRedirectState {
843+
/// The URL to redirect to after the login process is complete.
844+
#[serde(rename = "u")]
845+
initial_url: String,
846+
}
847+
791848
impl OidcLoginState {
792849
fn new(initial_url: String, auth_url: AuthUrlParams) -> Self {
793850
Self {
@@ -798,22 +855,68 @@ impl OidcLoginState {
798855
}
799856
}
800857

801-
fn create_state_cookie(login_state: &OidcLoginState) -> Cookie<'_> {
802-
let state_json = serde_json::to_string(login_state).unwrap();
803-
Cookie::build(SQLPAGE_STATE_COOKIE_NAME, state_json)
858+
fn create_state_cookies(login_state: &OidcLoginState) -> (Cookie<'_>, Cookie<'_>) {
859+
// Create nonce cookie (longer-lived for security)
860+
let nonce_state = OidcNonceState {
861+
nonce: login_state.nonce.clone(),
862+
};
863+
let nonce_json = serde_json::to_string(&nonce_state).unwrap();
864+
let nonce_cookie = Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, nonce_json)
865+
.secure(true)
866+
.http_only(true)
867+
.same_site(actix_web::cookie::SameSite::Lax)
868+
.path("/")
869+
.max_age(actix_web::cookie::time::Duration::minutes(10)) // 10 minutes should be enough for login flow
870+
.finish();
871+
872+
// Create state-specific redirect cookie (short-lived)
873+
let redirect_state = OidcRedirectState {
874+
initial_url: login_state.initial_url.clone(),
875+
};
876+
let redirect_json = serde_json::to_string(&redirect_state).unwrap();
877+
let state_cookie_name = format!(
878+
"{}{}",
879+
SQLPAGE_STATE_COOKIE_PREFIX,
880+
login_state.csrf_token.secret()
881+
);
882+
let redirect_cookie = Cookie::build(state_cookie_name, redirect_json)
804883
.secure(true)
805884
.http_only(true)
806885
.same_site(actix_web::cookie::SameSite::Lax)
807886
.path("/")
808-
.finish()
887+
.max_age(actix_web::cookie::time::Duration::minutes(5)) // Short-lived, only needed during login flow
888+
.finish();
889+
890+
(nonce_cookie, redirect_cookie)
809891
}
810892

811-
fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result<OidcLoginState> {
812-
let state_cookie = request.cookie(SQLPAGE_STATE_COOKIE_NAME).with_context(|| {
813-
format!("No {SQLPAGE_STATE_COOKIE_NAME} cookie found for {SQLPAGE_REDIRECT_URI}")
893+
fn get_state_from_cookies(
894+
request: &ServiceRequest,
895+
csrf_token: &CsrfToken,
896+
) -> anyhow::Result<OidcLoginState> {
897+
// Get nonce from the nonce cookie
898+
let nonce_cookie = request.cookie(SQLPAGE_NONCE_COOKIE_NAME).with_context(|| {
899+
format!("No {SQLPAGE_NONCE_COOKIE_NAME} cookie found for {SQLPAGE_REDIRECT_URI}")
814900
})?;
815-
serde_json::from_str(state_cookie.value())
816-
.with_context(|| format!("Failed to parse OIDC state from cookie: {state_cookie}"))
901+
let nonce_state: OidcNonceState = serde_json::from_str(nonce_cookie.value())
902+
.with_context(|| format!("Failed to parse OIDC nonce from cookie: {nonce_cookie}"))?;
903+
904+
// Get redirect URL from the state-specific cookie
905+
let state_cookie_name = format!("{}{}", SQLPAGE_STATE_COOKIE_PREFIX, csrf_token.secret());
906+
let redirect_cookie = request.cookie(&state_cookie_name).with_context(|| {
907+
format!("No {state_cookie_name} cookie found for {SQLPAGE_REDIRECT_URI}")
908+
})?;
909+
let redirect_state: OidcRedirectState = serde_json::from_str(redirect_cookie.value())
910+
.with_context(|| {
911+
format!("Failed to parse OIDC redirect state from cookie: {redirect_cookie}")
912+
})?;
913+
914+
// Reconstruct the full login state
915+
Ok(OidcLoginState {
916+
initial_url: redirect_state.initial_url,
917+
csrf_token: csrf_token.clone(),
918+
nonce: nonce_state.nonce,
919+
})
817920
}
818921

819922
/// Given an audience, verify if it is trusted. The `client_id` is always trusted, independently of this function.

0 commit comments

Comments
 (0)