Implement secure oidc logout endpoint and sql function (#1141)

* Checkpoint before follow-up message

Co-authored-by: contact <contact@ophir.dev>

* Checkpoint before follow-up message

Co-authored-by: contact <contact@ophir.dev>

* Checkpoint before follow-up message

Co-authored-by: contact <contact@ophir.dev>

* Checkpoint before follow-up message

Co-authored-by: contact <contact@ophir.dev>

* 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 <contact@ophir.dev>

* Refactor OIDC logout cookie removal

Co-authored-by: contact <contact@ophir.dev>

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.
This commit is contained in:
Ophir LOJKINE
2025-12-07 01:06:52 +01:00
committed by GitHub
parent 3ada9b496d
commit 083593d927
7 changed files with 373 additions and 26 deletions
+1
View File
@@ -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)
@@ -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'
);
+10 -1
View File
@@ -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;
-12
View File
@@ -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;
+4 -1
View File
@@ -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,
@@ -35,6 +35,8 @@ super::function_definition_macro::sqlpage_functions! {
headers((&RequestInfo));
hmac(data: Cow<str>, key: Cow<str>, algorithm: Option<Cow<str>>);
oidc_logout_url((&RequestInfo), redirect_uri: Option<Cow<str>>);
user_info_token((&RequestInfo));
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
@@ -858,6 +860,31 @@ async fn user_info_token(request: &RequestInfo) -> anyhow::Result<Option<String>
Ok(Some(serde_json::to_string(claims)?))
}
async fn oidc_logout_url<'a>(
request: &'a RequestInfo,
redirect_uri: Option<Cow<'a, str>>,
) -> anyhow::Result<Option<String>> {
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,
+209 -12
View File
@@ -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<T> = Pin<Box<dyn Future<Output = T> + '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<String> {
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<EndSessionUrl>,
last_update: Instant,
}
@@ -162,24 +176,25 @@ pub struct OidcState {
impl OidcState {
pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result<Self> {
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<EndSessionUrl> {
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<OidcClient> {
) -> anyhow::Result<(OidcClient, Option<EndSessionUrl>)> {
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<OidcClient> {
) -> anyhow::Result<(OidcClient, Option<EndSessionUrl>)> {
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<openidconnect::core::CoreProviderMetadata> {
) -> anyhow::Result<ProviderMetadataWithLogout> {
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<LogoutParams> {
Query::<LogoutParams>::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<HttpResponse> {
let params = parse_logout_params(request.query_string())?;
verify_logout_params(&params, &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, &params.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::<Sha256>::new_from_slice(client_secret.as_bytes())
.expect("HMAC accepts any key size");
mac.update(redirect_uri.as_bytes());
mac.update(&timestamp.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(&params.redirect_uri, params.timestamp, client_secret);
let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&params.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", &timestamp.to_string())
.append_pair("signature", &signature)
.finish();
format!(
"{}{}?{}",
site_prefix.trim_end_matches('/'),
SQLPAGE_LOGOUT_URI,
query
)
}
impl<S> Service<ServiceRequest> for OidcService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, 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<OidcClient> {
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(&params, secret).expect("generated URL should validate");
}
}