diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b9fe213..a25be690c 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) diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql index eda1c2cc9..71d1d8490 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/index.sql b/examples/single sign on/index.sql index 4557d1871..bc410f69d 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 855d5c684..000000000 --- a/examples/single sign on/logout.sql +++ /dev/null @@ -1,12 +0,0 @@ --- remove the session cookie -select - 'cookie' as component, - 'sqlpage_auth' as name, - true as remove; - -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; diff --git a/examples/single sign on/protected/index.sql b/examples/single sign on/protected/index.sql index 8956a6f61..b82ffe3f5 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/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 0ac7ed867..de15467b6 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 d86af4053..ac16126b3 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -23,9 +23,11 @@ use openidconnect::core::{ CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, }; use openidconnect::{ - core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet, - EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, 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, @@ -40,6 +42,7 @@ 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 OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60); @@ -149,8 +152,19 @@ fn get_app_host(config: &AppConfig) -> String { 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}"))?; + 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, last_update: Instant, } @@ -162,24 +176,25 @@ 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 +225,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 +262,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 +270,15 @@ 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 end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone(); let client = make_oidc_client(oidc_cfg, provider_metadata)?; - Ok(client) + Ok((client, end_session_endpoint)) } pub struct OidcMiddleware { @@ -273,15 +296,19 @@ impl OidcMiddleware { async fn discover_provider_metadata( http_client: &awc::Client, issuer_url: IssuerUrl, -) -> anyhow::Result { +) -> anyhow::Result { log::debug!("Discovering provider metadata for {issuer_url}"); - 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: {:?}", + provider_metadata.additional_metadata().end_session_endpoint + ); Ok(provider_metadata) } @@ -337,6 +364,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 +416,157 @@ 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 { + redirect_uri: String, + timestamp: i64, + signature: String, +} + +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(Query::into_inner) +} + +async fn process_oidc_logout( + oidc_state: &OidcState, + request: &ServiceRequest, +) -> anyhow::Result { + let params = parse_logout_params(request.query_string())?; + + 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 + .as_ref() + .map(|c| OidcToken::from_str(c.value())) + .transpose() + .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, ¶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}") + })?; + + 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 {}", + params.redirect_uri + ); + build_redirect_response(params.redirect_uri) + }; + + 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) +} + +fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &str) -> String { + use base64::Engine; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let mut mac = Hmac::::new_from_slice(client_secret.as_bytes()) + .expect("HMAC accepts any key size"); + mac.update(redirect_uri.as_bytes()); + mac.update(×tamp.to_be_bytes()); + let signature = mac.finalize().into_bytes(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature) +} + +fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::Result<()> { + use base64::Engine; + + let expected_signature = + compute_logout_signature(¶ms.redirect_uri, params.timestamp, client_secret); + + let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(¶ms.signature) + .with_context(|| "Invalid logout signature encoding")?; + + let expected_signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&expected_signature) + .with_context(|| "Failed to decode expected signature")?; + + if expected_signature_bytes[..] != provided_signature[..] { + anyhow::bail!("Invalid logout signature"); + } + + let now = chrono::Utc::now().timestamp(); + if now - params.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS { + anyhow::bail!("Logout token has expired"); + } + if params.timestamp > now + 60 { + anyhow::bail!("Logout token timestamp is in the future"); + } + + if !params.redirect_uri.starts_with('/') || params.redirect_uri.starts_with("//") { + anyhow::bail!("Invalid redirect URI"); + } + + Ok(()) +} + +#[must_use] +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!( + "{}{}?{}", + site_prefix.trim_end_matches('/'), + SQLPAGE_LOGOUT_URI, + query + ) +} + impl Service for OidcService where S: Service, Error = Error> + 'static, @@ -630,7 +813,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()); @@ -840,6 +1023,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() { @@ -868,4 +1052,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"); + } }