Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ When a user visits this URL, SQLPage will:
2. Redirect the user to the OIDC provider''s logout endpoint (if available)
3. Finally redirect back to the specified `redirect_uri`

The generated link now appends the current user''s OIDC `sub` identifier as the `user_id` query parameter, so you can display or log which account will be signed out. If no user is currently authenticated, the function simply returns the provided `redirect_uri`, avoiding unnecessary redirects.

## Security Features

This function provides protection against **Cross-Site Request Forgery (CSRF)** attacks:
Expand Down
5 changes: 5 additions & 0 deletions src/webserver/database/sqlpage_functions/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,10 +876,15 @@ async fn oidc_logout_url<'a>(
);
}

let Some(claims) = request.oidc_claims.as_ref() else {
return Ok(Some(redirect_uri.to_string()));
};

let logout_url = crate::webserver::oidc::create_logout_url(
redirect_uri,
&request.app_state.config.site_prefix,
&oidc_state.config.client_secret,
Some(claims.subject().as_str()),
);

Ok(Some(logout_url))
Expand Down
30 changes: 23 additions & 7 deletions src/webserver/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,14 +551,22 @@ fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::R
}

#[must_use]
pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String {
pub fn create_logout_url(
redirect_uri: &str,
site_prefix: &str,
client_secret: &str,
user_id: Option<&str>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cursoragent the user id should be mandatory

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a look!

Open in Cursor Open in Web

) -> String {
let timestamp = chrono::Utc::now().timestamp();
let signature = compute_logout_signature(redirect_uri, timestamp, client_secret);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cursoragent the user id should be part of the signed message

Copy link

@cursor cursor bot Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — right now the HMAC only covers redirect_uri and timestamp, so the user_id we append wouldn’t be protected. I’ll update compute_logout_signature (and verify_logout_params) to append the user_id bytes into the MAC input, make the query parameter required whenever we have a logged-in user, and adjust the tests accordingly so the entire logout URL (including the user id) is signed and validated.


Open in Cursor Open in Web

Learn more about Cursor Agents

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();
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("redirect_uri", redirect_uri);
serializer.append_pair("timestamp", &timestamp.to_string());
serializer.append_pair("signature", &signature);
if let Some(user_id) = user_id {
serializer.append_pair("user_id", user_id);
}
let query = serializer.finish();
format!(
"{}{}?{}",
site_prefix.trim_end_matches('/'),
Expand Down Expand Up @@ -1056,10 +1064,18 @@ mod tests {
#[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 user_id = "user-123";
let generated = create_logout_url("/after", "https://example.com", secret, Some(user_id));

let parsed = Url::parse(&generated).expect("generated URL should be valid");
assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI);
let mut pairs = parsed.query_pairs();
assert_eq!(
pairs
.find(|(key, _)| key == "user_id")
.map(|(_, value)| value.to_string()),
Some(user_id.to_string())
);

let params = parse_logout_params(parsed.query().expect("query string is present"))
.expect("generated URL should parse");
Expand Down