Skip to content

Commit 3de7584

Browse files
committed
Merge: resolve conflict in oidc tests, keeping both test functions
2 parents 3aa95a4 + 72eff80 commit 3de7584

4 files changed

Lines changed: 131 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
## unreleased
44
- fix: `sqlpage.variables()` now does not return json objects with duplicate keys when post, get and set variables of the same name are present. The semantics of the returned values remains the same (precedence: set > post > get).
55
- add support for some duckdb-specific syntax like `select {'a': 1, 'b': 2}` when connected to duckdb through odbc.
6+
- better oidc support. Single-sign-on now works with sites:
7+
- using a non-default `site_prefix`
8+
- hosted behind an ssl-terminating reverse proxy
69

710
## 0.41.0 (2025-12-28)
811
- **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)

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -882,11 +882,7 @@ async fn oidc_logout_url<'a>(
882882
);
883883
}
884884

885-
let logout_url = crate::webserver::oidc::create_logout_url(
886-
redirect_uri,
887-
&request.app_state.config.site_prefix,
888-
&oidc_state.config.client_secret,
889-
);
885+
let logout_url = oidc_state.config.create_logout_url(redirect_uri);
890886

891887
Ok(Some(logout_url))
892888
}

src/webserver/oidc.rs

Lines changed: 79 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ pub struct OidcConfig {
8080
pub scopes: Vec<Scope>,
8181
pub additional_audience_verifier: AudienceVerifier,
8282
pub site_prefix: String,
83+
pub redirect_uri: String,
84+
pub logout_uri: String,
8385
}
8486

8587
impl TryFrom<&AppConfig> for OidcConfig {
@@ -95,6 +97,10 @@ impl TryFrom<&AppConfig> for OidcConfig {
9597

9698
let app_host = get_app_host(config);
9799

100+
let site_prefix_trimmed = config.site_prefix.trim_end_matches('/');
101+
let redirect_uri = format!("{site_prefix_trimmed}{SQLPAGE_REDIRECT_URI}");
102+
let logout_uri = format!("{site_prefix_trimmed}{SQLPAGE_LOGOUT_URI}");
103+
98104
Ok(Self {
99105
issuer_url: issuer_url.clone(),
100106
client_id: config.oidc_client_id.clone(),
@@ -111,6 +117,8 @@ impl TryFrom<&AppConfig> for OidcConfig {
111117
config.oidc_additional_trusted_audiences.clone(),
112118
),
113119
site_prefix: config.site_prefix.clone(),
120+
redirect_uri,
121+
logout_uri,
114122
})
115123
}
116124
}
@@ -131,6 +139,19 @@ impl OidcConfig {
131139
.id_token_verifier()
132140
.set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn())
133141
}
142+
143+
/// Creates a logout URL with the given redirect URI
144+
#[must_use]
145+
pub fn create_logout_url(&self, redirect_uri: &str) -> String {
146+
let timestamp = chrono::Utc::now().timestamp();
147+
let signature = compute_logout_signature(redirect_uri, timestamp, &self.client_secret);
148+
let query = form_urlencoded::Serializer::new(String::new())
149+
.append_pair("redirect_uri", redirect_uri)
150+
.append_pair("timestamp", &timestamp.to_string())
151+
.append_pair("signature", &signature)
152+
.finish();
153+
format!("{}?{}", self.logout_uri, query)
154+
}
134155
}
135156

136157
fn get_app_host(config: &AppConfig) -> String {
@@ -157,16 +178,6 @@ fn get_app_host(config: &AppConfig) -> String {
157178
host
158179
}
159180

160-
fn build_absolute_uri(app_host: &str, relative_path: &str, scheme: &str) -> anyhow::Result<String> {
161-
let mut base_url = Url::parse(&format!("{scheme}://{app_host}"))
162-
.with_context(|| format!("Failed to parse app_host: {app_host}"))?;
163-
base_url.set_path("");
164-
let absolute_url = base_url
165-
.join(relative_path)
166-
.with_context(|| format!("Failed to join path {relative_path}"))?;
167-
Ok(absolute_url.to_string())
168-
}
169-
170181
pub struct ClientWithTime {
171182
client: OidcClient,
172183
end_session_endpoint: Option<EndSessionUrl>,
@@ -248,6 +259,29 @@ impl OidcState {
248259
.map_err(|e| anyhow::anyhow!("Could not verify the ID token: {e}"))?;
249260
Ok(claims)
250261
}
262+
263+
/// Builds an absolute redirect URI by joining the relative redirect URI with the client's redirect URL
264+
pub async fn build_absolute_redirect_uri(
265+
&self,
266+
relative_redirect_uri: &str,
267+
) -> anyhow::Result<String> {
268+
let client_guard = self.get_client().await;
269+
let client_redirect_url = client_guard
270+
.redirect_uri()
271+
.ok_or_else(|| anyhow!("OIDC client has no redirect URL configured"))?;
272+
let absolute_redirect_uri = client_redirect_url
273+
.url()
274+
.join(relative_redirect_uri)
275+
.with_context(|| {
276+
format!(
277+
"Failed to join redirect URI {} with client redirect URL {}",
278+
relative_redirect_uri,
279+
client_redirect_url.url()
280+
)
281+
})?
282+
.to_string();
283+
Ok(absolute_redirect_uri)
284+
}
251285
}
252286

253287
pub async fn initialize_oidc_state(
@@ -364,23 +398,12 @@ async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> Midd
364398
log::trace!("Started OIDC middleware request handling");
365399
oidc_state.refresh_if_expired(&request).await;
366400

367-
let redirect_uri = format!(
368-
"{}{}",
369-
oidc_state.config.site_prefix.trim_end_matches('/'),
370-
SQLPAGE_REDIRECT_URI
371-
);
372-
let logout_uri = format!(
373-
"{}{}",
374-
oidc_state.config.site_prefix.trim_end_matches('/'),
375-
SQLPAGE_LOGOUT_URI
376-
);
377-
378-
if request.path() == redirect_uri {
401+
if request.path() == oidc_state.config.redirect_uri {
379402
let response = handle_oidc_callback(oidc_state, request).await;
380403
return MiddlewareResponse::Respond(response);
381404
}
382405

383-
if request.path() == logout_uri {
406+
if request.path() == oidc_state.config.logout_uri {
384407
let response = handle_oidc_logout(oidc_state, request).await;
385408
return MiddlewareResponse::Respond(response);
386409
}
@@ -507,11 +530,12 @@ async fn process_oidc_logout(
507530
.ok()
508531
.flatten();
509532

510-
let scheme = request.connection_info().scheme().to_string();
511533
let mut response =
512534
if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await {
513-
let absolute_redirect_uri =
514-
build_absolute_uri(&oidc_state.config.app_host, &params.redirect_uri, &scheme)?;
535+
let absolute_redirect_uri = oidc_state
536+
.build_absolute_redirect_uri(&params.redirect_uri)
537+
.await?;
538+
515539
let post_logout_redirect_uri =
516540
PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()).with_context(|| {
517541
format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}")
@@ -596,23 +620,6 @@ fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::R
596620
Ok(())
597621
}
598622

599-
#[must_use]
600-
pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String {
601-
let timestamp = chrono::Utc::now().timestamp();
602-
let signature = compute_logout_signature(redirect_uri, timestamp, client_secret);
603-
let query = form_urlencoded::Serializer::new(String::new())
604-
.append_pair("redirect_uri", redirect_uri)
605-
.append_pair("timestamp", &timestamp.to_string())
606-
.append_pair("signature", &signature)
607-
.finish();
608-
format!(
609-
"{}{}?{}",
610-
site_prefix.trim_end_matches('/'),
611-
SQLPAGE_LOGOUT_URI,
612-
query
613-
)
614-
}
615-
616623
impl<S> Service<ServiceRequest> for OidcService<S>
617624
where
618625
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error> + 'static,
@@ -654,7 +661,7 @@ async fn process_oidc_callback(
654661
redirect_target,
655662
} = parse_login_flow_state(&tmp_login_flow_state_cookie)?;
656663
let redirect_target =
657-
validate_redirect_url(redirect_target.to_string(), &oidc_state.config.site_prefix);
664+
validate_redirect_url(redirect_target.to_string(), &oidc_state.config.redirect_uri);
658665

659666
log::info!("Redirecting to {redirect_target} after a successful login");
660667
let mut response = build_redirect_response(redirect_target);
@@ -898,28 +905,25 @@ fn make_oidc_client(
898905
let client_id = openidconnect::ClientId::new(config.client_id.clone());
899906
let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone());
900907

901-
let redirect_path = format!(
902-
"{}{}",
903-
config.site_prefix.trim_end_matches('/'),
904-
SQLPAGE_REDIRECT_URI
905-
);
906-
let mut redirect_url =
907-
RedirectUrl::new(format!("https://{}{}", config.app_host, redirect_path,)).with_context(
908-
|| {
909-
format!(
910-
"Failed to build the redirect URL; invalid app host \"{}\"",
911-
config.app_host
912-
)
913-
},
914-
)?;
908+
let mut redirect_url = RedirectUrl::new(format!(
909+
"https://{}{}",
910+
config.app_host, config.redirect_uri,
911+
))
912+
.with_context(|| {
913+
format!(
914+
"Failed to build the redirect URL; invalid app host \"{}\"",
915+
config.app_host
916+
)
917+
})?;
915918
let needs_http = match redirect_url.url().host() {
916919
Some(openidconnect::url::Host::Domain(domain)) => domain == "localhost",
917920
Some(openidconnect::url::Host::Ipv4(_) | openidconnect::url::Host::Ipv6(_)) => true,
918921
None => false,
919922
};
920923
if needs_http {
921924
log::debug!("App host seems to be local, changing redirect URL to HTTP");
922-
redirect_url = RedirectUrl::new(format!("http://{}{}", config.app_host, redirect_path,))?;
925+
redirect_url =
926+
RedirectUrl::new(format!("http://{}{}", config.app_host, config.redirect_uri,))?;
923927
}
924928
log::info!("OIDC redirect URL for {}: {redirect_url}", config.client_id);
925929
let client =
@@ -1092,13 +1096,8 @@ impl AudienceVerifier {
10921096
}
10931097

10941098
/// Validate that a redirect URL is safe to use (prevents open redirect attacks)
1095-
fn validate_redirect_url(url: String, site_prefix: &str) -> String {
1096-
let redirect_uri = format!(
1097-
"{}{}",
1098-
site_prefix.trim_end_matches('/'),
1099-
SQLPAGE_REDIRECT_URI
1100-
);
1101-
if url.starts_with('/') && !url.starts_with("//") && !url.starts_with(&redirect_uri) {
1099+
fn validate_redirect_url(url: String, redirect_uri: &str) -> String {
1100+
if url.starts_with('/') && !url.starts_with("//") && !url.starts_with(redirect_uri) {
11021101
return url;
11031102
}
11041103
log::warn!("Refusing to redirect to {url}");
@@ -1142,7 +1141,20 @@ mod tests {
11421141
#[test]
11431142
fn logout_url_generation_and_parsing_are_compatible() {
11441143
let secret = "super_secret_key";
1145-
let generated = create_logout_url("/after", "https://example.com", secret);
1144+
let config = OidcConfig {
1145+
issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(),
1146+
client_id: "test_client".to_string(),
1147+
client_secret: secret.to_string(),
1148+
protected_paths: vec![],
1149+
public_paths: vec![],
1150+
app_host: "example.com".to_string(),
1151+
scopes: vec![],
1152+
additional_audience_verifier: AudienceVerifier::new(None),
1153+
site_prefix: "https://example.com".to_string(),
1154+
redirect_uri: format!("https://example.com{SQLPAGE_REDIRECT_URI}"),
1155+
logout_uri: format!("https://example.com{SQLPAGE_LOGOUT_URI}"),
1156+
};
1157+
let generated = config.create_logout_url("/after");
11461158

11471159
let parsed = Url::parse(&generated).expect("generated URL should be valid");
11481160
assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI);

tests/oidc/mod.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ struct DiscoveryResponse {
6464
response_types_supported: Vec<String>,
6565
subject_types_supported: Vec<String>,
6666
id_token_signing_alg_values_supported: Vec<String>,
67+
end_session_endpoint: String,
6768
}
6869

6970
#[derive(Serialize)]
@@ -89,6 +90,7 @@ async fn discovery_endpoint(state: Data<SharedProviderState>) -> impl Responder
8990
response_types_supported: vec!["code".to_string()],
9091
subject_types_supported: vec!["public".to_string()],
9192
id_token_signing_alg_values_supported: vec!["HS256".to_string()],
93+
end_session_endpoint: format!("{}/logout", state.issuer_url),
9294
};
9395
HttpResponse::Ok()
9496
.insert_header((header::CONTENT_TYPE, "application/json"))
@@ -500,3 +502,49 @@ async fn test_oidc_with_site_prefix() {
500502
redirect_uri
501503
);
502504
}
505+
506+
#[actix_web::test]
507+
async fn test_oidc_logout_uses_correct_scheme() {
508+
use sqlpage::{
509+
app_config::{test_database_url, AppConfig},
510+
AppState,
511+
};
512+
513+
crate::common::init_log();
514+
let provider = FakeOidcProvider::new().await;
515+
516+
let db_url = test_database_url();
517+
let config_json = format!(
518+
r#"{{
519+
"database_url": "{db_url}",
520+
"oidc_issuer_url": "{}",
521+
"oidc_client_id": "{}",
522+
"oidc_client_secret": "{}",
523+
"https_domain": "example.com"
524+
}}"#,
525+
provider.issuer_url, provider.client_id, provider.client_secret
526+
);
527+
528+
let config: AppConfig = serde_json::from_str(&config_json).unwrap();
529+
let app_state = AppState::init(&config).await.unwrap();
530+
let logout_path = app_state
531+
.oidc_state
532+
.as_ref()
533+
.unwrap()
534+
.config
535+
.create_logout_url("/logged_out");
536+
let app = test::init_service(create_app(Data::new(app_state))).await;
537+
// make sure the logout path includes the configured domain
538+
assert!(logout_path.starts_with("/sqlpage/oidc_logout"));
539+
540+
let req = test::TestRequest::get().uri(&logout_path).to_request();
541+
let resp = test::call_service(&app, req).await;
542+
543+
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
544+
let location = resp.headers().get("location").unwrap().to_str().unwrap();
545+
let location_url = Url::parse(location).unwrap();
546+
assert_eq!(location_url.path(), "/logout");
547+
let params: HashMap<String, String> = location_url.query_pairs().into_owned().collect();
548+
let post_logout = params.get("post_logout_redirect_uri").unwrap();
549+
assert_eq!(post_logout, "https://example.com/logged_out");
550+
}

0 commit comments

Comments
 (0)