From b6453ff6dca453e755dec99fec419a6a75425b9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Dec 2025 22:31:17 +0000 Subject: [PATCH 01/13] Checkpoint before follow-up message Co-authored-by: contact --- src/webserver/oidc.rs | 182 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 9 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index d86af405..0cbeb4a2 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -38,10 +38,18 @@ use super::http_client::make_http_client; type LocalBoxFuture = Pin + 'static>>; +#[derive(Clone, Debug, Deserialize)] +struct DiscoveryMetadata { + #[serde(default)] + end_session_endpoint: Option, +} + const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth"; const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback"; +const SQLPAGE_LOGOUT_URI: &str = "/sqlpage/oidc_logout"; const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce"; const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_"; +const SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX: &str = "sqlpage_logout_state_"; const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60); const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5); const AUTH_COOKIE_EXPIRATION: awc::cookie::time::Duration = @@ -151,6 +159,7 @@ fn get_app_host(config: &AppConfig) -> String { pub struct ClientWithTime { client: OidcClient, + end_session_endpoint: Option, last_update: Instant, } @@ -162,24 +171,26 @@ pub struct OidcState { impl OidcState { pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result { let http_client = make_http_client(&app_config)?; - let client = build_oidc_client(&oidc_cfg, &http_client).await?; + let (client, end_session_endpoint) = + build_oidc_client(&oidc_cfg, &http_client).await?; Ok(Self { config: oidc_cfg, client: RwLock::new(ClientWithTime { client, + end_session_endpoint, last_update: Instant::now(), }), }) } async fn refresh(&self, service_request: &ServiceRequest) { - // Obtain a write lock to prevent concurrent OIDC client refreshes. let mut write_guard = self.client.write().await; match build_oidc_client_from_appdata(&self.config, service_request).await { - Ok(http_client) => { + Ok((http_client, end_session_endpoint)) => { *write_guard = ClientWithTime { client: http_client, + end_session_endpoint, last_update: Instant::now(), } } @@ -210,6 +221,10 @@ impl OidcState { ) } + pub async fn get_end_session_endpoint(&self) -> Option { + self.client.read().await.end_session_endpoint.clone() + } + /// Validate and decode the claims of an OIDC token, without refreshing the client. async fn get_token_claims( &self, @@ -243,7 +258,7 @@ pub async fn initialize_oidc_state( async fn build_oidc_client_from_appdata( cfg: &OidcConfig, req: &ServiceRequest, -) -> anyhow::Result { +) -> anyhow::Result<(OidcClient, Option)> { let http_client = get_http_client_from_appdata(req)?; build_oidc_client(cfg, http_client).await } @@ -251,11 +266,12 @@ async fn build_oidc_client_from_appdata( async fn build_oidc_client( oidc_cfg: &OidcConfig, http_client: &Client, -) -> anyhow::Result { +) -> anyhow::Result<(OidcClient, Option)> { let issuer_url = oidc_cfg.issuer_url.clone(); - let provider_metadata = discover_provider_metadata(http_client, issuer_url.clone()).await?; + let (provider_metadata, end_session_endpoint) = + discover_provider_metadata(http_client, issuer_url.clone()).await?; let client = make_oidc_client(oidc_cfg, provider_metadata)?; - Ok(client) + Ok((client, end_session_endpoint)) } pub struct OidcMiddleware { @@ -273,8 +289,27 @@ impl OidcMiddleware { async fn discover_provider_metadata( http_client: &awc::Client, issuer_url: IssuerUrl, -) -> anyhow::Result { +) -> anyhow::Result<(openidconnect::core::CoreProviderMetadata, Option)> { log::debug!("Discovering provider metadata for {issuer_url}"); + + let discovery_url = issuer_url + .join(".well-known/openid-configuration") + .with_context(|| { + format!("Failed to construct discovery URL from issuer URL: {issuer_url}") + })?; + + let response = http_client + .get(discovery_url.as_str()) + .send() + .await + .map_err(|e| anyhow!("Failed to fetch OIDC discovery document: {e}"))? + .body() + .await + .map_err(|e| anyhow!("Failed to read OIDC discovery document body: {e}"))?; + + let extra_metadata: DiscoveryMetadata = serde_json::from_slice(&response) + .with_context(|| "Failed to parse end_session_endpoint from discovery document")?; + let provider_metadata = openidconnect::core::CoreProviderMetadata::discover_async( issuer_url, &AwcHttpClient::from_client(http_client), @@ -282,7 +317,9 @@ async fn discover_provider_metadata( .await .with_context(|| "Failed to discover OIDC provider metadata".to_string())?; log::debug!("Provider metadata discovered: {provider_metadata:?}"); - Ok(provider_metadata) + log::debug!("end_session_endpoint: {:?}", extra_metadata.end_session_endpoint); + + Ok((provider_metadata, extra_metadata.end_session_endpoint)) } impl Transform for OidcMiddleware @@ -337,6 +374,11 @@ async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> Midd return MiddlewareResponse::Respond(response); } + if request.path() == SQLPAGE_LOGOUT_URI { + let response = handle_oidc_logout(oidc_state, request).await; + return MiddlewareResponse::Respond(response); + } + match get_authenticated_user_info(oidc_state, &request).await { Ok(Some(claims)) => { log::trace!("Storing authenticated user info in request extensions: {claims:?}"); @@ -384,6 +426,128 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) - } } +async fn handle_oidc_logout(oidc_state: &OidcState, request: ServiceRequest) -> ServiceResponse { + match process_oidc_logout(oidc_state, &request).await { + Ok(response) => request.into_response(response), + Err(e) => { + log::error!("Failed to process OIDC logout: {e:#}"); + request.into_response( + HttpResponse::BadRequest() + .content_type("text/plain") + .body(format!("Logout failed: {e}")), + ) + } + } +} + +#[derive(Debug, Deserialize)] +struct LogoutParams { + state: CsrfToken, +} + +async fn process_oidc_logout( + oidc_state: &OidcState, + request: &ServiceRequest, +) -> anyhow::Result { + let params = Query::::from_query(request.query_string()) + .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing or invalid state parameter"))? + .into_inner(); + + let state_cookie = get_logout_state_cookie(request, ¶ms.state)?; + let LogoutState { redirect_uri } = parse_logout_state(&state_cookie)?; + let redirect_uri = redirect_uri.to_string(); + + let id_token = request.cookie(SQLPAGE_AUTH_COOKIE_NAME); + + let mut response = if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await + { + let mut logout_url = end_session_endpoint; + { + let mut query_pairs = logout_url.query_pairs_mut(); + query_pairs.append_pair("post_logout_redirect_uri", &redirect_uri); + if let Some(ref token) = id_token { + query_pairs.append_pair("id_token_hint", token.value()); + } + } + log::info!("Redirecting to OIDC logout URL: {logout_url}"); + build_redirect_response(logout_url.to_string()) + } else { + log::info!("No end_session_endpoint, redirecting to {redirect_uri}"); + build_redirect_response(redirect_uri) + }; + + let auth_cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") + .secure(true) + .http_only(true) + .max_age(actix_web::cookie::time::Duration::ZERO) + .path("/") + .finish(); + response.add_removal_cookie(&auth_cookie)?; + + let nonce_cookie = Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, "") + .secure(true) + .http_only(true) + .max_age(actix_web::cookie::time::Duration::ZERO) + .path("/") + .finish(); + response.add_removal_cookie(&nonce_cookie)?; + + let mut logout_state_cookie = state_cookie; + logout_state_cookie.set_path("/"); + response.add_removal_cookie(&logout_state_cookie)?; + + log::debug!("User logged out successfully"); + Ok(response) +} + +#[derive(Debug, Serialize, Deserialize)] +struct LogoutState<'a> { + #[serde(rename = "r")] + redirect_uri: &'a str, +} + +fn get_logout_state_cookie( + request: &ServiceRequest, + csrf_token: &CsrfToken, +) -> anyhow::Result> { + let cookie_name = SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); + request + .cookie(&cookie_name) + .with_context(|| format!("Invalid or expired logout state. Cookie {cookie_name} not found.")) +} + +fn parse_logout_state<'a>(cookie: &'a Cookie<'_>) -> anyhow::Result> { + serde_json::from_str(cookie.value()) + .with_context(|| format!("Invalid logout state cookie: {}", cookie.value())) +} + +pub fn create_logout_url_with_state(redirect_uri: &str, site_prefix: &str) -> (String, Cookie<'_>) { + let csrf_token = CsrfToken::new_random(); + let cookie_name = SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); + let cookie_value = serde_json::to_string(&LogoutState { redirect_uri }) + .expect("logout state is always serializable"); + + let cookie = Cookie::build(cookie_name, cookie_value) + .secure(true) + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .path("/") + .max_age(LOGIN_FLOW_STATE_COOKIE_EXPIRATION) + .finish(); + + let logout_url = format!( + "{}{}?state={}", + site_prefix.trim_end_matches('/'), + SQLPAGE_LOGOUT_URI, + percent_encoding::percent_encode( + csrf_token.secret().as_bytes(), + percent_encoding::NON_ALPHANUMERIC + ) + ); + + (logout_url, cookie) +} + impl Service for OidcService where S: Service, Error = Error> + 'static, From f775297b6508d9bbf49867868998795cc0d6bae7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Dec 2025 22:32:16 +0000 Subject: [PATCH 02/13] Checkpoint before follow-up message Co-authored-by: contact --- src/webserver/oidc.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 0cbeb4a2..8d855c8b 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -38,11 +38,6 @@ use super::http_client::make_http_client; type LocalBoxFuture = Pin + 'static>>; -#[derive(Clone, Debug, Deserialize)] -struct DiscoveryMetadata { - #[serde(default)] - end_session_endpoint: Option, -} const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth"; const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback"; From cfca0cf3138dfa583d121fb4bc2d729d61371ce1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Dec 2025 22:33:12 +0000 Subject: [PATCH 03/13] Checkpoint before follow-up message Co-authored-by: contact --- src/webserver/oidc.rs | 50 ++++++++++++++++--------------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 8d855c8b..1e5a5418 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -24,8 +24,8 @@ use openidconnect::core::{ }; use openidconnect::{ core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet, - EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, - TokenResponse, + EndpointNotSet, EndpointSet, EndSessionUrl, IssuerUrl, LogoutRequest, Nonce, OAuth2TokenResponse, + PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope, TokenResponse, }; use openidconnect::{ EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse, @@ -154,7 +154,7 @@ fn get_app_host(config: &AppConfig) -> String { pub struct ClientWithTime { client: OidcClient, - end_session_endpoint: Option, + end_session_endpoint: Option, last_update: Instant, } @@ -216,7 +216,7 @@ impl OidcState { ) } - pub async fn get_end_session_endpoint(&self) -> Option { + pub async fn get_end_session_endpoint(&self) -> Option { self.client.read().await.end_session_endpoint.clone() } @@ -253,7 +253,7 @@ pub async fn initialize_oidc_state( async fn build_oidc_client_from_appdata( cfg: &OidcConfig, req: &ServiceRequest, -) -> anyhow::Result<(OidcClient, Option)> { +) -> anyhow::Result<(OidcClient, Option)> { let http_client = get_http_client_from_appdata(req)?; build_oidc_client(cfg, http_client).await } @@ -261,10 +261,13 @@ async fn build_oidc_client_from_appdata( async fn build_oidc_client( oidc_cfg: &OidcConfig, http_client: &Client, -) -> anyhow::Result<(OidcClient, Option)> { +) -> anyhow::Result<(OidcClient, Option)> { let issuer_url = oidc_cfg.issuer_url.clone(); - let (provider_metadata, end_session_endpoint) = - discover_provider_metadata(http_client, issuer_url.clone()).await?; + let provider_metadata = discover_provider_metadata(http_client, issuer_url.clone()).await?; + let end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone(); let client = make_oidc_client(oidc_cfg, provider_metadata)?; Ok((client, end_session_endpoint)) } @@ -284,37 +287,20 @@ impl OidcMiddleware { async fn discover_provider_metadata( http_client: &awc::Client, issuer_url: IssuerUrl, -) -> anyhow::Result<(openidconnect::core::CoreProviderMetadata, Option)> { +) -> anyhow::Result { log::debug!("Discovering provider metadata for {issuer_url}"); - - let discovery_url = issuer_url - .join(".well-known/openid-configuration") - .with_context(|| { - format!("Failed to construct discovery URL from issuer URL: {issuer_url}") - })?; - - let response = http_client - .get(discovery_url.as_str()) - .send() - .await - .map_err(|e| anyhow!("Failed to fetch OIDC discovery document: {e}"))? - .body() - .await - .map_err(|e| anyhow!("Failed to read OIDC discovery document body: {e}"))?; - - let extra_metadata: DiscoveryMetadata = serde_json::from_slice(&response) - .with_context(|| "Failed to parse end_session_endpoint from discovery document")?; - - let provider_metadata = openidconnect::core::CoreProviderMetadata::discover_async( + let provider_metadata = ProviderMetadataWithLogout::discover_async( issuer_url, &AwcHttpClient::from_client(http_client), ) .await .with_context(|| "Failed to discover OIDC provider metadata".to_string())?; log::debug!("Provider metadata discovered: {provider_metadata:?}"); - log::debug!("end_session_endpoint: {:?}", extra_metadata.end_session_endpoint); - - Ok((provider_metadata, extra_metadata.end_session_endpoint)) + log::debug!( + "end_session_endpoint: {:?}", + provider_metadata.additional_metadata().end_session_endpoint + ); + Ok(provider_metadata) } impl Transform for OidcMiddleware From ce8539e2e6ddd60daa8cc48c3b3531784b0af516 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Dec 2025 22:33:49 +0000 Subject: [PATCH 04/13] Checkpoint before follow-up message Co-authored-by: contact --- src/webserver/oidc.rs | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 1e5a5418..cb186092 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -436,26 +436,34 @@ async fn process_oidc_logout( let state_cookie = get_logout_state_cookie(request, ¶ms.state)?; let LogoutState { redirect_uri } = parse_logout_state(&state_cookie)?; - let redirect_uri = redirect_uri.to_string(); - let id_token = request.cookie(SQLPAGE_AUTH_COOKIE_NAME); + let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME); + let id_token = id_token_cookie + .as_ref() + .map(|c| OidcToken::from_str(c.value())) + .transpose() + .ok() + .flatten(); + + let mut response = + if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await { + let post_logout_redirect_uri = PostLogoutRedirectUrl::new(redirect_uri.to_string()) + .with_context(|| format!("Invalid post_logout_redirect_uri: {redirect_uri}"))?; + + let mut logout_request = LogoutRequest::from(end_session_endpoint) + .set_post_logout_redirect_uri(post_logout_redirect_uri); - let mut response = if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await - { - let mut logout_url = end_session_endpoint; - { - let mut query_pairs = logout_url.query_pairs_mut(); - query_pairs.append_pair("post_logout_redirect_uri", &redirect_uri); if let Some(ref token) = id_token { - query_pairs.append_pair("id_token_hint", token.value()); + logout_request = logout_request.set_id_token_hint(token); } - } - log::info!("Redirecting to OIDC logout URL: {logout_url}"); - build_redirect_response(logout_url.to_string()) - } else { - log::info!("No end_session_endpoint, redirecting to {redirect_uri}"); - build_redirect_response(redirect_uri) - }; + + let logout_url = logout_request.http_get_url(); + log::info!("Redirecting to OIDC logout URL: {logout_url}"); + build_redirect_response(logout_url.to_string()) + } else { + log::info!("No end_session_endpoint, redirecting to {redirect_uri}"); + build_redirect_response(redirect_uri.to_string()) + }; let auth_cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") .secure(true) From 9aa89fee44c1af2c82d338d5e428b1cb949b6281 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Dec 2025 22:42:41 +0000 Subject: [PATCH 05/13] feat: Add OIDC logout functionality This commit introduces the `oidc_logout_url` function, allowing users to securely log out of OIDC-authenticated applications. It includes CSRF protection and handles redirection to the OIDC provider's logout endpoint. Co-authored-by: contact --- .../sqlpage/migrations/61_oidc_functions.sql | 122 ++++++++++++ examples/single sign on/logout.sql | 16 +- .../database/sqlpage_functions/functions.rs | 27 +++ src/webserver/oidc.rs | 188 +++++++++++------- 4 files changed, 275 insertions(+), 78 deletions(-) diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql index eda1c2cc..71d1d849 100644 --- a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql +++ b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql @@ -169,4 +169,126 @@ VALUES 'claim', 'The name of the user information to retrieve. Common values include ''name'', ''email'', ''picture'', ''sub'', ''preferred_username'', ''given_name'', and ''family_name''. The exact values available depend on your OIDC provider and configuration.', 'TEXT' + ); + +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'oidc_logout_url', + '0.41.0', + 'logout', + '# Secure OIDC Logout + +The `sqlpage.oidc_logout_url` function generates a secure logout URL for users authenticated via [OIDC Single Sign-On](/sso). + +When a user visits this URL, SQLPage will: +1. Remove the authentication cookie +2. Redirect the user to the OIDC provider''s logout endpoint (if available) +3. Finally redirect back to the specified `redirect_uri` + +## Security Features + +This function provides protection against **Cross-Site Request Forgery (CSRF)** attacks: +- The generated URL contains a cryptographically signed token +- The token includes a timestamp and expires after 10 minutes +- The token is signed using your OIDC client secret +- Only relative URLs (starting with `/`) are allowed as redirect targets + +This means that malicious websites cannot trick your users into logging out by simply including an image or link to your logout URL. + +## How to Use + +```sql +select ''button'' as component; +select + ''Logout'' as title, + sqlpage.oidc_logout_url(''/'') as link, + ''logout'' as icon, + ''red'' as outline; +``` + +This creates a logout button that, when clicked: +1. Logs the user out of your SQLPage application +2. Logs the user out of the OIDC provider (if the provider supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)) +3. Redirects the user back to your homepage (`/`) + +## Examples + +### Logout Button in Navigation + +```sql +select ''shell'' as component, + ''My App'' as title, + json_array( + json_object( + ''title'', ''Logout'', + ''link'', sqlpage.oidc_logout_url(''/''), + ''icon'', ''logout'' + ) + ) as menu_item; +``` + +### Logout with Return to Current Page + +```sql +select ''button'' as component; +select + ''Sign Out'' as title, + sqlpage.oidc_logout_url(sqlpage.path()) as link; +``` + +### Conditional Logout Link + +```sql +select ''button'' as component +where sqlpage.user_info(''sub'') is not null; +select + ''Logout '' || sqlpage.user_info(''name'') as title, + sqlpage.oidc_logout_url(''/'') as link +where sqlpage.user_info(''sub'') is not null; +``` + +## Requirements + +- OIDC must be [configured](/sso) in your `sqlpage.json` +- If OIDC is not configured, this function returns NULL +- The `redirect_uri` must be a relative path starting with `/` + +## Provider Support + +The logout behavior depends on your OIDC provider: + +| Provider | Full Logout Support | +|----------|-------------------| +| Keycloak | ✅ Yes | +| Auth0 | ✅ Yes | +| Google | ❌ No (local logout only) | +| Azure AD | ✅ Yes | +| Okta | ✅ Yes | + +When the provider doesn''t support RP-Initiated Logout, SQLPage will still remove the local authentication cookie and redirect to your specified URI. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'oidc_logout_url', + 1, + 'redirect_uri', + 'The relative URL path where the user should be redirected after logout. Must start with `/`. Defaults to `/` if not provided.', + 'TEXT' ); \ No newline at end of file diff --git a/examples/single sign on/logout.sql b/examples/single sign on/logout.sql index 855d5c68..c0230780 100644 --- a/examples/single sign on/logout.sql +++ b/examples/single sign on/logout.sql @@ -1,12 +1,10 @@ --- remove the session cookie -select - 'cookie' as component, - 'sqlpage_auth' as name, - true as remove; +-- Secure OIDC logout with CSRF protection +-- This redirects to /sqlpage/oidc_logout which: +-- 1. Verifies the CSRF token +-- 2. Removes the auth cookies +-- 3. Redirects to the OIDC provider's logout endpoint +-- 4. Finally redirects back to the homepage select 'redirect' as component, - sqlpage.link('http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/logout', json_object( - 'post_logout_redirect_uri', 'http://localhost:8080/', - 'id_token_hint', sqlpage.cookie('sqlpage_auth') - )) as link; + sqlpage.oidc_logout_url('/') as link; diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 0ac7ed86..de15467b 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -35,6 +35,8 @@ super::function_definition_macro::sqlpage_functions! { headers((&RequestInfo)); hmac(data: Cow, key: Cow, algorithm: Option>); + oidc_logout_url((&RequestInfo), redirect_uri: Option>); + user_info_token((&RequestInfo)); link(file: Cow, parameters: Option>, hash: Option>); @@ -858,6 +860,31 @@ async fn user_info_token(request: &RequestInfo) -> anyhow::Result Ok(Some(serde_json::to_string(claims)?)) } +async fn oidc_logout_url<'a>( + request: &'a RequestInfo, + redirect_uri: Option>, +) -> anyhow::Result> { + let Some(oidc_state) = &request.app_state.oidc_state else { + return Ok(None); + }; + + let redirect_uri = redirect_uri.as_deref().unwrap_or("/"); + + if !redirect_uri.starts_with('/') || redirect_uri.starts_with("//") { + anyhow::bail!( + "oidc_logout_url: redirect_uri must be a relative path starting with '/'. Got: {redirect_uri}" + ); + } + + let logout_url = crate::webserver::oidc::create_logout_url( + redirect_uri, + &request.app_state.config.site_prefix, + &oidc_state.config.client_secret, + ); + + Ok(Some(logout_url)) +} + /// Returns a specific claim from the ID token. async fn user_info<'a>( request: &'a RequestInfo, diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index cb186092..7091bb2d 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -23,9 +23,10 @@ use openidconnect::core::{ CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, }; use openidconnect::{ - core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet, - EndpointNotSet, EndpointSet, EndSessionUrl, IssuerUrl, LogoutRequest, Nonce, OAuth2TokenResponse, - PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope, TokenResponse, + core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndSessionUrl, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, LogoutRequest, Nonce, + OAuth2TokenResponse, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope, + TokenResponse, }; use openidconnect::{ EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse, @@ -38,13 +39,11 @@ use super::http_client::make_http_client; type LocalBoxFuture = Pin + 'static>>; - const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth"; const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback"; const SQLPAGE_LOGOUT_URI: &str = "/sqlpage/oidc_logout"; const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce"; const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_"; -const SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX: &str = "sqlpage_logout_state_"; const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60); const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5); const AUTH_COOKIE_EXPIRATION: awc::cookie::time::Duration = @@ -166,8 +165,7 @@ pub struct OidcState { impl OidcState { pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result { let http_client = make_http_client(&app_config)?; - let (client, end_session_endpoint) = - build_oidc_client(&oidc_cfg, &http_client).await?; + let (client, end_session_endpoint) = build_oidc_client(&oidc_cfg, &http_client).await?; Ok(Self { config: oidc_cfg, @@ -423,19 +421,21 @@ async fn handle_oidc_logout(oidc_state: &OidcState, request: ServiceRequest) -> #[derive(Debug, Deserialize)] struct LogoutParams { - state: CsrfToken, + token: String, } +const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600; + async fn process_oidc_logout( oidc_state: &OidcState, request: &ServiceRequest, ) -> anyhow::Result { let params = Query::::from_query(request.query_string()) - .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing or invalid state parameter"))? + .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing token parameter"))? .into_inner(); - let state_cookie = get_logout_state_cookie(request, ¶ms.state)?; - let LogoutState { redirect_uri } = parse_logout_state(&state_cookie)?; + let logout_state = + verify_and_decode_logout_token(¶ms.token, &oidc_state.config.client_secret)?; let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME); let id_token = id_token_cookie @@ -445,25 +445,34 @@ async fn process_oidc_logout( .ok() .flatten(); - let mut response = - if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await { - let post_logout_redirect_uri = PostLogoutRedirectUrl::new(redirect_uri.to_string()) - .with_context(|| format!("Invalid post_logout_redirect_uri: {redirect_uri}"))?; - - let mut logout_request = LogoutRequest::from(end_session_endpoint) - .set_post_logout_redirect_uri(post_logout_redirect_uri); - - if let Some(ref token) = id_token { - logout_request = logout_request.set_id_token_hint(token); - } + let mut response = if let Some(end_session_endpoint) = + oidc_state.get_end_session_endpoint().await + { + let post_logout_redirect_uri = + PostLogoutRedirectUrl::new(logout_state.redirect_uri.clone()).with_context(|| { + format!( + "Invalid post_logout_redirect_uri: {}", + logout_state.redirect_uri + ) + })?; + + let mut logout_request = LogoutRequest::from(end_session_endpoint) + .set_post_logout_redirect_uri(post_logout_redirect_uri); + + if let Some(ref token) = id_token { + logout_request = logout_request.set_id_token_hint(token); + } - let logout_url = logout_request.http_get_url(); - log::info!("Redirecting to OIDC logout URL: {logout_url}"); - build_redirect_response(logout_url.to_string()) - } else { - log::info!("No end_session_endpoint, redirecting to {redirect_uri}"); - build_redirect_response(redirect_uri.to_string()) - }; + let logout_url = logout_request.http_get_url(); + log::info!("Redirecting to OIDC logout URL: {logout_url}"); + build_redirect_response(logout_url.to_string()) + } else { + log::info!( + "No end_session_endpoint, redirecting to {}", + logout_state.redirect_uri + ); + build_redirect_response(logout_state.redirect_uri) + }; let auth_cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") .secure(true) @@ -481,60 +490,101 @@ async fn process_oidc_logout( .finish(); response.add_removal_cookie(&nonce_cookie)?; - let mut logout_state_cookie = state_cookie; - logout_state_cookie.set_path("/"); - response.add_removal_cookie(&logout_state_cookie)?; - log::debug!("User logged out successfully"); Ok(response) } #[derive(Debug, Serialize, Deserialize)] -struct LogoutState<'a> { +struct LogoutTokenPayload { #[serde(rename = "r")] - redirect_uri: &'a str, + redirect_uri: String, + #[serde(rename = "t")] + timestamp: i64, } -fn get_logout_state_cookie( - request: &ServiceRequest, - csrf_token: &CsrfToken, -) -> anyhow::Result> { - let cookie_name = SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); - request - .cookie(&cookie_name) - .with_context(|| format!("Invalid or expired logout state. Cookie {cookie_name} not found.")) -} +fn create_logout_token(redirect_uri: &str, client_secret: &str) -> String { + use base64::Engine; + use hmac::{Hmac, Mac}; + use sha2::Sha256; -fn parse_logout_state<'a>(cookie: &'a Cookie<'_>) -> anyhow::Result> { - serde_json::from_str(cookie.value()) - .with_context(|| format!("Invalid logout state cookie: {}", cookie.value())) + let timestamp = chrono::Utc::now().timestamp(); + let payload = LogoutTokenPayload { + redirect_uri: redirect_uri.to_string(), + timestamp, + }; + let payload_json = serde_json::to_string(&payload).expect("payload is always serializable"); + let payload_b64 = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + + let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) + .expect("HMAC accepts any key size"); + mac.update(payload_b64.as_bytes()); + let signature = mac.finalize().into_bytes(); + let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature); + + format!("{payload_b64}.{signature_b64}") } -pub fn create_logout_url_with_state(redirect_uri: &str, site_prefix: &str) -> (String, Cookie<'_>) { - let csrf_token = CsrfToken::new_random(); - let cookie_name = SQLPAGE_LOGOUT_STATE_COOKIE_PREFIX.to_owned() + csrf_token.secret(); - let cookie_value = serde_json::to_string(&LogoutState { redirect_uri }) - .expect("logout state is always serializable"); +fn verify_and_decode_logout_token( + token: &str, + client_secret: &str, +) -> anyhow::Result { + use base64::Engine; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + anyhow::bail!("Invalid logout token format"); + } - let cookie = Cookie::build(cookie_name, cookie_value) - .secure(true) - .http_only(true) - .same_site(actix_web::cookie::SameSite::Lax) - .path("/") - .max_age(LOGIN_FLOW_STATE_COOKIE_EXPIRATION) - .finish(); + let payload_b64 = parts[0]; + let signature_b64 = parts[1]; + + let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) + .expect("HMAC accepts any key size"); + mac.update(payload_b64.as_bytes()); + + let expected_signature = mac.finalize().into_bytes(); + let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(signature_b64) + .with_context(|| "Invalid logout token signature encoding")?; + + if expected_signature[..] != provided_signature[..] { + anyhow::bail!("Invalid logout token signature"); + } + + let payload_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .with_context(|| "Invalid logout token payload encoding")?; + + let payload: LogoutTokenPayload = + serde_json::from_slice(&payload_json).with_context(|| "Invalid logout token payload")?; - let logout_url = format!( - "{}{}?state={}", + let now = chrono::Utc::now().timestamp(); + if now - payload.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS { + anyhow::bail!("Logout token has expired"); + } + if payload.timestamp > now + 60 { + anyhow::bail!("Logout token timestamp is in the future"); + } + + if !payload.redirect_uri.starts_with('/') || payload.redirect_uri.starts_with("//") { + anyhow::bail!("Invalid redirect URI in logout token"); + } + + Ok(payload) +} + +#[must_use] +pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String { + let token = create_logout_token(redirect_uri, client_secret); + format!( + "{}{}?token={}", site_prefix.trim_end_matches('/'), SQLPAGE_LOGOUT_URI, - percent_encoding::percent_encode( - csrf_token.secret().as_bytes(), - percent_encoding::NON_ALPHANUMERIC - ) - ); - - (logout_url, cookie) + percent_encoding::percent_encode(token.as_bytes(), percent_encoding::NON_ALPHANUMERIC) + ) } impl Service for OidcService @@ -783,7 +833,7 @@ impl std::error::Error for AwcWrapperError { fn make_oidc_client( config: &OidcConfig, - provider_metadata: openidconnect::core::CoreProviderMetadata, + provider_metadata: ProviderMetadataWithLogout, ) -> anyhow::Result { let client_id = openidconnect::ClientId::new(config.client_id.clone()); let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone()); From b9be8aab4a9305dc56ebfce6e616a926b1fcc4b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Dec 2025 09:45:15 +0000 Subject: [PATCH 06/13] Refactor OIDC logout cookie removal Co-authored-by: contact --- src/webserver/oidc.rs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 7091bb2d..a2596425 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -474,21 +474,15 @@ async fn process_oidc_logout( build_redirect_response(logout_state.redirect_uri) }; - let auth_cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") - .secure(true) - .http_only(true) - .max_age(actix_web::cookie::time::Duration::ZERO) - .path("/") - .finish(); - response.add_removal_cookie(&auth_cookie)?; - - let nonce_cookie = Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, "") - .secure(true) - .http_only(true) - .max_age(actix_web::cookie::time::Duration::ZERO) - .path("/") - .finish(); - response.add_removal_cookie(&nonce_cookie)?; + let mut auth_cookie = Cookie::named(SQLPAGE_AUTH_COOKIE_NAME); + auth_cookie.set_path("/"); + auth_cookie.make_removal(); + response.add_cookie(&auth_cookie)?; + + let mut nonce_cookie = Cookie::named(SQLPAGE_NONCE_COOKIE_NAME); + nonce_cookie.set_path("/"); + nonce_cookie.make_removal(); + response.add_cookie(&nonce_cookie)?; log::debug!("User logged out successfully"); Ok(response) From f00533767c48523c4743fa802209071543cc8dbf Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sat, 6 Dec 2025 00:18:59 +0100 Subject: [PATCH 07/13] feat: Implement OIDC logout with CSRF protection This commit implements secure OIDC logout by: - Using sqlpage.oidc_logout_url() to generate the logout URL. - Ensuring CSRF protection during the logout process. - Redirecting to the OIDC provider's logout endpoint. - Redirecting back to the homepage after logout. - Adding absolute URI for post logout redirect URI. --- examples/single sign on/index.sql | 11 +++- examples/single sign on/logout.sql | 10 --- examples/single sign on/protected/index.sql | 5 +- src/webserver/oidc.rs | 72 +++++++++++++-------- 4 files changed, 59 insertions(+), 39 deletions(-) delete mode 100644 examples/single sign on/logout.sql diff --git a/examples/single sign on/index.sql b/examples/single sign on/index.sql index 4557d187..bc410f69 100644 --- a/examples/single sign on/index.sql +++ b/examples/single sign on/index.sql @@ -14,5 +14,14 @@ WHERE $email IS NULL; -- For logged-in users SELECT 'text' as component, 'Welcome back, ' || sqlpage.user_info('name') || '!' as title, - 'You are logged in as ' || sqlpage.user_info('email') || '. You can now access the [protected page](/protected) or [log out](/logout).' as contents_md + 'You are logged in as ' || sqlpage.user_info('email') || + '. You can now access the [protected page](/protected) or [log out](' || + -- Secure OIDC logout with CSRF protection + -- This redirects to /sqlpage/oidc_logout which: + -- 1. Verifies the CSRF token + -- 2. Removes the auth cookies + -- 3. Redirects to the OIDC provider's logout endpoint + -- 4. Finally redirects back to the homepage + sqlpage.oidc_logout_url() + || ').' as contents_md WHERE $email IS NOT NULL; diff --git a/examples/single sign on/logout.sql b/examples/single sign on/logout.sql deleted file mode 100644 index c0230780..00000000 --- a/examples/single sign on/logout.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Secure OIDC logout with CSRF protection --- This redirects to /sqlpage/oidc_logout which: --- 1. Verifies the CSRF token --- 2. Removes the auth cookies --- 3. Redirects to the OIDC provider's logout endpoint --- 4. Finally redirects back to the homepage - -select - 'redirect' as component, - sqlpage.oidc_logout_url('/') as link; diff --git a/examples/single sign on/protected/index.sql b/examples/single sign on/protected/index.sql index 8956a6f6..b82ffe3f 100644 --- a/examples/single sign on/protected/index.sql +++ b/examples/single sign on/protected/index.sql @@ -1,7 +1,10 @@ set user_email = sqlpage.user_info('email'); select 'shell' as component, 'My secure app' as title, - json_object('title', 'Log Out', 'link', '/logout') as menu_item; + json_object( + 'title', 'Log Out', + 'link', sqlpage.oidc_logout_url() + ) as menu_item; select 'text' as component, 'You''re in, '|| sqlpage.user_info('name') || ' !' as title, diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index a2596425..08c19c38 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -151,6 +151,26 @@ fn get_app_host(config: &AppConfig) -> String { host } +fn build_absolute_uri(app_host: &str, relative_path: &str) -> anyhow::Result { + let mut base_url = Url::parse(&format!("https://{app_host}")) + .with_context(|| format!("Failed to parse app_host: {app_host}"))?; + let needs_http = match base_url.host() { + Some(openidconnect::url::Host::Domain(domain)) => domain == "localhost", + Some(openidconnect::url::Host::Ipv4(_) | openidconnect::url::Host::Ipv6(_)) => true, + None => false, + }; + if needs_http { + base_url + .set_scheme("http") + .map_err(|()| anyhow!("Failed to set URL scheme to http"))?; + } + base_url.set_path(""); + let absolute_url = base_url + .join(relative_path) + .with_context(|| format!("Failed to join path {relative_path}"))?; + Ok(absolute_url.to_string()) +} + pub struct ClientWithTime { client: OidcClient, end_session_endpoint: Option, @@ -445,34 +465,32 @@ async fn process_oidc_logout( .ok() .flatten(); - let mut response = if let Some(end_session_endpoint) = - oidc_state.get_end_session_endpoint().await - { - let post_logout_redirect_uri = - PostLogoutRedirectUrl::new(logout_state.redirect_uri.clone()).with_context(|| { - format!( - "Invalid post_logout_redirect_uri: {}", - logout_state.redirect_uri - ) - })?; - - let mut logout_request = LogoutRequest::from(end_session_endpoint) - .set_post_logout_redirect_uri(post_logout_redirect_uri); - - if let Some(ref token) = id_token { - logout_request = logout_request.set_id_token_hint(token); - } + let mut response = + if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await { + let absolute_redirect_uri = + build_absolute_uri(&oidc_state.config.app_host, &logout_state.redirect_uri)?; + let post_logout_redirect_uri = + PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()).with_context(|| { + format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}") + })?; - let logout_url = logout_request.http_get_url(); - log::info!("Redirecting to OIDC logout URL: {logout_url}"); - build_redirect_response(logout_url.to_string()) - } else { - log::info!( - "No end_session_endpoint, redirecting to {}", - logout_state.redirect_uri - ); - build_redirect_response(logout_state.redirect_uri) - }; + let mut logout_request = LogoutRequest::from(end_session_endpoint) + .set_post_logout_redirect_uri(post_logout_redirect_uri); + + if let Some(ref token) = id_token { + logout_request = logout_request.set_id_token_hint(token); + } + + let logout_url = logout_request.http_get_url(); + log::info!("Redirecting to OIDC logout URL: {logout_url}"); + build_redirect_response(logout_url.to_string()) + } else { + log::info!( + "No end_session_endpoint, redirecting to {}", + logout_state.redirect_uri + ); + build_redirect_response(logout_state.redirect_uri) + }; let mut auth_cookie = Cookie::named(SQLPAGE_AUTH_COOKIE_NAME); auth_cookie.set_path("/"); From 1b0912c58610aa43f569a789794497b927c40968 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:10:35 +0100 Subject: [PATCH 08/13] refactor: Enhance build_absolute_uri function to accept scheme parameter This commit modifies the build_absolute_uri function to include a scheme parameter, allowing for more flexible URL construction. The function now dynamically sets the URL scheme based on the request context, improving compatibility with different environments. --- src/webserver/oidc.rs | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 08c19c38..461ac9ef 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -151,19 +151,13 @@ fn get_app_host(config: &AppConfig) -> String { host } -fn build_absolute_uri(app_host: &str, relative_path: &str) -> anyhow::Result { - let mut base_url = Url::parse(&format!("https://{app_host}")) +fn build_absolute_uri( + app_host: &str, + relative_path: &str, + scheme: &str, +) -> anyhow::Result { + let mut base_url = Url::parse(&format!("{scheme}://{app_host}")) .with_context(|| format!("Failed to parse app_host: {app_host}"))?; - let needs_http = match base_url.host() { - Some(openidconnect::url::Host::Domain(domain)) => domain == "localhost", - Some(openidconnect::url::Host::Ipv4(_) | openidconnect::url::Host::Ipv6(_)) => true, - None => false, - }; - if needs_http { - base_url - .set_scheme("http") - .map_err(|()| anyhow!("Failed to set URL scheme to http"))?; - } base_url.set_path(""); let absolute_url = base_url .join(relative_path) @@ -441,7 +435,9 @@ async fn handle_oidc_logout(oidc_state: &OidcState, request: ServiceRequest) -> #[derive(Debug, Deserialize)] struct LogoutParams { - token: String, + redirect_uri: String, + timestamp: i64, + signature: String, } const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600; @@ -451,11 +447,10 @@ async fn process_oidc_logout( request: &ServiceRequest, ) -> anyhow::Result { let params = Query::::from_query(request.query_string()) - .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing token parameter"))? + .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters"))? .into_inner(); - let logout_state = - verify_and_decode_logout_token(¶ms.token, &oidc_state.config.client_secret)?; + verify_logout_params(¶ms, &oidc_state.config.client_secret)?; let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME); let id_token = id_token_cookie @@ -465,10 +460,14 @@ async fn process_oidc_logout( .ok() .flatten(); + let scheme = request.connection_info().scheme().to_string(); let mut response = if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await { - let absolute_redirect_uri = - build_absolute_uri(&oidc_state.config.app_host, &logout_state.redirect_uri)?; + let absolute_redirect_uri = build_absolute_uri( + &oidc_state.config.app_host, + &logout_state.redirect_uri, + &scheme, + )?; let post_logout_redirect_uri = PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()).with_context(|| { format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}") From 485f3f99bb5f9461ef8446ee14686d90fd91d9a0 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:21:12 +0100 Subject: [PATCH 09/13] refactor: Simplify OIDC logout processing and enhance logout token handling This commit refactors the OIDC logout process by introducing a new function, `parse_logout_params`, to streamline the extraction of logout parameters from the request. It also updates the logout token creation and verification logic, improving security by ensuring the signature is computed correctly. Additionally, the `create_logout_url` function is modified to include a timestamp and signature in the generated URL, enhancing the logout flow's integrity. --- src/webserver/oidc.rs | 129 ++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 74 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 461ac9ef..f3a8b590 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -151,11 +151,7 @@ fn get_app_host(config: &AppConfig) -> String { host } -fn build_absolute_uri( - app_host: &str, - relative_path: &str, - scheme: &str, -) -> anyhow::Result { +fn build_absolute_uri(app_host: &str, relative_path: &str, scheme: &str) -> anyhow::Result { let mut base_url = Url::parse(&format!("{scheme}://{app_host}")) .with_context(|| format!("Failed to parse app_host: {app_host}"))?; base_url.set_path(""); @@ -442,13 +438,17 @@ struct LogoutParams { const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600; +fn parse_logout_params(query: &str) -> anyhow::Result { + Query::::from_query(query) + .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters")) + .map(|q| q.into_inner()) +} + async fn process_oidc_logout( oidc_state: &OidcState, request: &ServiceRequest, ) -> anyhow::Result { - let params = Query::::from_query(request.query_string()) - .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters"))? - .into_inner(); + let params = parse_logout_params(request.query_string())?; verify_logout_params(¶ms, &oidc_state.config.client_secret)?; @@ -463,11 +463,8 @@ async fn process_oidc_logout( let scheme = request.connection_info().scheme().to_string(); let mut response = if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await { - let absolute_redirect_uri = build_absolute_uri( - &oidc_state.config.app_host, - &logout_state.redirect_uri, - &scheme, - )?; + let absolute_redirect_uri = + build_absolute_uri(&oidc_state.config.app_host, ¶ms.redirect_uri, &scheme)?; let post_logout_redirect_uri = PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()).with_context(|| { format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}") @@ -486,9 +483,9 @@ async fn process_oidc_logout( } else { log::info!( "No end_session_endpoint, redirecting to {}", - logout_state.redirect_uri + params.redirect_uri ); - build_redirect_response(logout_state.redirect_uri) + build_redirect_response(params.redirect_uri) }; let mut auth_cookie = Cookie::named(SQLPAGE_AUTH_COOKIE_NAME); @@ -505,96 +502,66 @@ async fn process_oidc_logout( Ok(response) } -#[derive(Debug, Serialize, Deserialize)] -struct LogoutTokenPayload { - #[serde(rename = "r")] - redirect_uri: String, - #[serde(rename = "t")] - timestamp: i64, -} - -fn create_logout_token(redirect_uri: &str, client_secret: &str) -> String { +fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &str) -> String { use base64::Engine; use hmac::{Hmac, Mac}; use sha2::Sha256; - let timestamp = chrono::Utc::now().timestamp(); - let payload = LogoutTokenPayload { - redirect_uri: redirect_uri.to_string(), - timestamp, - }; - let payload_json = serde_json::to_string(&payload).expect("payload is always serializable"); - let payload_b64 = - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); - let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) .expect("HMAC accepts any key size"); - mac.update(payload_b64.as_bytes()); + mac.update(redirect_uri.as_bytes()); + mac.update(×tamp.to_be_bytes()); let signature = mac.finalize().into_bytes(); - let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature); - - format!("{payload_b64}.{signature_b64}") + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature) } -fn verify_and_decode_logout_token( - token: &str, - client_secret: &str, -) -> anyhow::Result { +fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::Result<()> { use base64::Engine; - use hmac::{Hmac, Mac}; - use sha2::Sha256; - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 2 { - anyhow::bail!("Invalid logout token format"); - } + let expected_signature = + compute_logout_signature(¶ms.redirect_uri, params.timestamp, client_secret); - let payload_b64 = parts[0]; - let signature_b64 = parts[1]; - - let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) - .expect("HMAC accepts any key size"); - mac.update(payload_b64.as_bytes()); - - let expected_signature = mac.finalize().into_bytes(); let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(signature_b64) - .with_context(|| "Invalid logout token signature encoding")?; + .decode(¶ms.signature) + .with_context(|| "Invalid logout signature encoding")?; - if expected_signature[..] != provided_signature[..] { - anyhow::bail!("Invalid logout token signature"); - } - - let payload_json = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .with_context(|| "Invalid logout token payload encoding")?; + let expected_signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&expected_signature) + .with_context(|| "Failed to decode expected signature")?; - let payload: LogoutTokenPayload = - serde_json::from_slice(&payload_json).with_context(|| "Invalid logout token payload")?; + if expected_signature_bytes[..] != provided_signature[..] { + anyhow::bail!("Invalid logout signature"); + } let now = chrono::Utc::now().timestamp(); - if now - payload.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS { + if now - params.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS { anyhow::bail!("Logout token has expired"); } - if payload.timestamp > now + 60 { + if params.timestamp > now + 60 { anyhow::bail!("Logout token timestamp is in the future"); } - if !payload.redirect_uri.starts_with('/') || payload.redirect_uri.starts_with("//") { - anyhow::bail!("Invalid redirect URI in logout token"); + if !params.redirect_uri.starts_with('/') || params.redirect_uri.starts_with("//") { + anyhow::bail!("Invalid redirect URI"); } - Ok(payload) + Ok(()) } #[must_use] pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String { - let token = create_logout_token(redirect_uri, client_secret); + let timestamp = chrono::Utc::now().timestamp(); + let signature = compute_logout_signature(redirect_uri, timestamp, client_secret); format!( - "{}{}?token={}", + "{}{}?redirect_uri={}×tamp={}&signature={}", site_prefix.trim_end_matches('/'), SQLPAGE_LOGOUT_URI, - percent_encoding::percent_encode(token.as_bytes(), percent_encoding::NON_ALPHANUMERIC) + percent_encoding::percent_encode( + redirect_uri.as_bytes(), + percent_encoding::NON_ALPHANUMERIC + ), + timestamp, + percent_encoding::percent_encode(signature.as_bytes(), percent_encoding::NON_ALPHANUMERIC) ) } @@ -1054,6 +1021,7 @@ fn validate_redirect_url(url: String) -> String { mod tests { use super::*; use actix_web::http::StatusCode; + use openidconnect::url::Url; #[test] fn login_redirects_use_see_other() { @@ -1082,4 +1050,17 @@ mod tests { .expect("Auth0 returns updated_at as RFC3339 string, not unix timestamp"); assert!(claims.updated_at().is_some()); } + + #[test] + fn logout_url_generation_and_parsing_are_compatible() { + let secret = "super_secret_key"; + let generated = create_logout_url("/after", "https://example.com", secret); + + let parsed = Url::parse(&generated).expect("generated URL should be valid"); + assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI); + + let params = parse_logout_params(parsed.query().expect("query string is present")) + .expect("generated URL should parse"); + verify_logout_params(¶ms, secret).expect("generated URL should validate"); + } } From 864ca9d179b9a44db153e9fa66104e666af3b16f Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:29:22 +0100 Subject: [PATCH 10/13] refactor: Improve logout URL generation and parameter parsing This commit refines the `create_logout_url` function to utilize a query string builder for constructing the logout URL, enhancing readability and maintainability. Additionally, the `parse_logout_params` function is updated to use `Query::into_inner`, streamlining the extraction of logout parameters from the request. --- src/webserver/oidc.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index f3a8b590..ec05122d 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -23,10 +23,11 @@ use openidconnect::core::{ CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, }; use openidconnect::{ - core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndSessionUrl, - EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, LogoutRequest, Nonce, - OAuth2TokenResponse, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope, - TokenResponse, + core::CoreAuthenticationFlow, + url::{form_urlencoded, Url}, + AsyncHttpClient, Audience, CsrfToken, EndSessionUrl, EndpointMaybeSet, EndpointNotSet, + EndpointSet, IssuerUrl, LogoutRequest, Nonce, OAuth2TokenResponse, PostLogoutRedirectUrl, + ProviderMetadataWithLogout, RedirectUrl, Scope, TokenResponse, }; use openidconnect::{ EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse, @@ -441,7 +442,7 @@ const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600; fn parse_logout_params(query: &str) -> anyhow::Result { Query::::from_query(query) .with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters")) - .map(|q| q.into_inner()) + .map(Query::into_inner) } async fn process_oidc_logout( @@ -552,16 +553,16 @@ fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::R pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String { let timestamp = chrono::Utc::now().timestamp(); let signature = compute_logout_signature(redirect_uri, timestamp, client_secret); + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("redirect_uri", redirect_uri) + .append_pair("timestamp", ×tamp.to_string()) + .append_pair("signature", &signature) + .finish(); format!( - "{}{}?redirect_uri={}×tamp={}&signature={}", + "{}{}?{}", site_prefix.trim_end_matches('/'), SQLPAGE_LOGOUT_URI, - percent_encoding::percent_encode( - redirect_uri.as_bytes(), - percent_encoding::NON_ALPHANUMERIC - ), - timestamp, - percent_encoding::percent_encode(signature.as_bytes(), percent_encoding::NON_ALPHANUMERIC) + query ) } From 8ac9200c7d6abfa06edbf5b25ddf2ad207b12d20 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:38:52 +0100 Subject: [PATCH 11/13] refactor: Streamline cookie removal in OIDC logout process This commit simplifies the removal of authentication and nonce cookies during the OIDC logout process by consolidating the cookie removal logic into a single method call for each cookie, enhancing code clarity and maintainability. --- src/webserver/oidc.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index ec05122d..6953ee20 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -489,15 +489,8 @@ async fn process_oidc_logout( build_redirect_response(params.redirect_uri) }; - let mut auth_cookie = Cookie::named(SQLPAGE_AUTH_COOKIE_NAME); - auth_cookie.set_path("/"); - auth_cookie.make_removal(); - response.add_cookie(&auth_cookie)?; - - let mut nonce_cookie = Cookie::named(SQLPAGE_NONCE_COOKIE_NAME); - nonce_cookie.set_path("/"); - nonce_cookie.make_removal(); - response.add_cookie(&nonce_cookie)?; + response.add_removal_cookie(&Cookie::named(SQLPAGE_AUTH_COOKIE_NAME))?; + response.add_removal_cookie(&Cookie::named(SQLPAGE_NONCE_COOKIE_NAME))?; log::debug!("User logged out successfully"); Ok(response) From fb80f9a90b9d6c91b02548b10a0beae80ee5e0a4 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:46:17 +0100 Subject: [PATCH 12/13] refactor: Enhance cookie removal logic in OIDC logout process This commit updates the cookie removal process during OIDC logout by utilizing the `Cookie::build` method to specify cookie attributes, improving clarity and ensuring proper cookie handling. --- src/webserver/oidc.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 6953ee20..ac16126b 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -489,8 +489,16 @@ async fn process_oidc_logout( build_redirect_response(params.redirect_uri) }; - response.add_removal_cookie(&Cookie::named(SQLPAGE_AUTH_COOKIE_NAME))?; - response.add_removal_cookie(&Cookie::named(SQLPAGE_NONCE_COOKIE_NAME))?; + response.add_removal_cookie( + &Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "") + .path("/") + .finish(), + )?; + response.add_removal_cookie( + &Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, "") + .path("/") + .finish(), + )?; log::debug!("User logged out successfully"); Ok(response) From f680c148160ec0e16ea884b1b52124d9dbc55901 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 7 Dec 2025 00:58:29 +0100 Subject: [PATCH 13/13] chore: Update CHANGELOG for version 0.40.1 - Added new function `sqlpage.oidc_logout_url(redirect_uri)` to generate secure logout URLs for OIDC users, supporting RP-Initiated Logout. - Fixed compatibility issues with Auth0 for OpenID-Connect authentication. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b9fe21..a25be690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## 0.40.1 + - **New Function**: `sqlpage.oidc_logout_url(redirect_uri)` - Generates a secure logout URL for OIDC-authenticated users with support for [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout) - Fix compatibility with Auth0 for OpenID-Connect authentification. See https://github.com/ramosbugs/openidconnect-rs/issues/23 ## 0.40.0 (2025-11-28)