diff --git a/configuration.md b/configuration.md index fc85c3f5..2fd78166 100644 --- a/configuration.md +++ b/configuration.md @@ -31,6 +31,7 @@ Here are the available configuration options and their default values: | `oidc_client_id` | sqlpage | The ID that identifies your SQLPage application to the OIDC provider. You get this when registering your app with the provider. | | `oidc_client_secret` | | The secret key for your SQLPage application. Keep this confidential as it allows your app to authenticate with the OIDC provider. | | `oidc_scopes` | openid email profile | Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) your app requests from the OIDC provider. | +| `oidc_additional_trusted_audiences` | unset | A list of additional audiences that are allowed in JWT tokens, beyond the client ID. When empty or unset, any additional audience is accepted. For increased security, set to an empty list `[]` to only allow the client ID as audience. | | `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. | | `compress_responses` | true | When the client supports it, compress the http response body. This can save bandwidth and speed up page loading on slow connections, but can also increase CPU usage and cause rendering delays on pages that take time to render (because streaming responses are buffered for longer than necessary). | | `https_domain` | | Domain name to request a certificate for. Setting this parameter will automatically make SQLPage listen on port 443 and request an SSL certificate. The server will take a little bit longer to start the first time it has to request a certificate. | diff --git a/examples/single sign on/sqlpage/sqlpage.yaml b/examples/single sign on/sqlpage/sqlpage.yaml index 98f0163e..7eeace52 100644 --- a/examples/single sign on/sqlpage/sqlpage.yaml +++ b/examples/single sign on/sqlpage/sqlpage.yaml @@ -1,7 +1,6 @@ -oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo -oidc_client_id: sqlpage +oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo # Given by keycloak as the "OpenID Endpoint Configuration" url. +oidc_client_id: sqlpage # configured in keycloak (http://localhost:8181/admin/master/console/#/sqlpage_demo/clients/a2bec2b8-f850-405e-9f26-59063ffa6f08/settings) oidc_client_secret: qiawfnYrYzsmoaOZT28rRjPPRamfvrYr # For a safer setup, use environment variables to store this -oidc_protected_paths: - - /protected # Makes the website root is publicly accessible, requiring authentication only for the /protected path -oidc_public_paths: - - /protected/public # Adds an exception for the /protected/public path, which is publicly accessible too +oidc_protected_paths: ["/protected"] # Makes the website root is publicly accessible, requiring authentication only for the /protected path +oidc_public_paths: ["/protected/public"] # Adds an exception for the /protected/public path, which is publicly accessible too +oidc_additional_trusted_audiences: [] # For increased security, reject any token that has more than just the client ID in the "aud" claim diff --git a/src/app_config.rs b/src/app_config.rs index 6b2ef78d..bcf509be 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -223,6 +223,14 @@ pub struct AppConfig { #[serde(default)] pub oidc_public_paths: Vec, + /// Additional trusted audiences for OIDC JWT tokens, beyond the client ID. + /// By default (when None), all additional audiences are trusted for compatibility + /// with providers that include multiple audience values (like ZITADEL, Azure AD, etc.). + /// Set to an empty list to only allow the client ID as audience. + /// Set to a specific list to only allow those specific additional audiences. + #[serde(default)] + pub oidc_additional_trusted_audiences: Option>, + /// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary /// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the /// server's IP address. diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 53a7522e..aaec925e 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::future::ready; use std::{future::Future, pin::Pin, str::FromStr, sync::Arc}; @@ -15,7 +16,7 @@ use anyhow::{anyhow, Context}; use awc::Client; use chrono::Utc; use openidconnect::{ - core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, CsrfToken, EndpointMaybeSet, + core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, TokenResponse, }; @@ -52,6 +53,7 @@ pub struct OidcConfig { pub public_paths: Vec, pub app_host: String, pub scopes: Vec, + pub additional_audience_verifier: AudienceVerifier, } impl TryFrom<&AppConfig> for OidcConfig { @@ -79,6 +81,9 @@ impl TryFrom<&AppConfig> for OidcConfig { .map(|s| Scope::new(s.to_string())) .collect(), app_host: app_host.clone(), + additional_audience_verifier: AudienceVerifier::new( + config.oidc_additional_trusted_audiences.clone(), + ), }) } } @@ -89,6 +94,16 @@ impl OidcConfig { !self.protected_paths.iter().any(|p| path.starts_with(p)) || self.public_paths.iter().any(|p| path.starts_with(p)) } + + /// Creates a custom ID token verifier that supports multiple issuers + fn create_id_token_verifier<'a>( + &'a self, + oidc_client: &'a OidcClient, + ) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> { + oidc_client + .id_token_verifier() + .set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn()) + } } fn get_app_host(config: &AppConfig) -> String { @@ -241,7 +256,7 @@ where Box::pin(async move { let query_string = request.query_string(); - match process_oidc_callback(&oidc_client, query_string, &request).await { + match process_oidc_callback(&oidc_client, &oidc_config, query_string, &request).await { Ok(response) => Ok(request.into_response(response)), Err(e) => { log::error!("Failed to process OIDC callback with params {query_string}: {e}"); @@ -282,7 +297,8 @@ where log::trace!("Started OIDC middleware request handling"); let oidc_client = Arc::clone(&self.oidc_state.client); - match get_authenticated_user_info(&oidc_client, &request) { + let oidc_config = Arc::clone(&self.oidc_state.config); + match get_authenticated_user_info(&oidc_client, &oidc_config, &request) { Ok(Some(claims)) => { if request.path() == SQLPAGE_REDIRECT_URI { return handle_authenticated_oidc_callback(request); @@ -315,6 +331,7 @@ where async fn process_oidc_callback( oidc_client: &OidcClient, + oidc_config: &Arc, query_string: &str, request: &ServiceRequest, ) -> anyhow::Result { @@ -342,7 +359,7 @@ async fn process_oidc_callback( let redirect_target = validate_redirect_url(state.initial_url); log::info!("Redirecting to {redirect_target} after a successful login"); let mut response = build_redirect_response(redirect_target); - set_auth_cookie(&mut response, &token_response, oidc_client)?; + set_auth_cookie(&mut response, &token_response, oidc_client, oidc_config)?; Ok(response) } @@ -364,6 +381,7 @@ fn set_auth_cookie( response: &mut HttpResponse, token_response: &openidconnect::core::CoreTokenResponse, oidc_client: &OidcClient, + oidc_config: &Arc, ) -> anyhow::Result<()> { let access_token = token_response.access_token(); log::trace!("Received access token: {}", access_token.secret()); @@ -371,7 +389,7 @@ fn set_auth_cookie( .id_token() .context("No ID token found in the token response. You may have specified an oauth2 provider that does not support OIDC.")?; - let id_token_verifier = oidc_client.id_token_verifier(); + let id_token_verifier = oidc_config.create_id_token_verifier(oidc_client); let nonce_verifier = |_nonce: Option<&Nonce>| Ok(()); // The nonce will be verified in request handling let claims = id_token.claims(&id_token_verifier, nonce_verifier)?; let expiration = claims.expiration(); @@ -420,6 +438,7 @@ fn build_redirect_response(target_url: String) -> HttpResponse { /// Returns the claims from the ID token in the `SQLPage` auth cookie. fn get_authenticated_user_info( oidc_client: &OidcClient, + config: &Arc, request: &ServiceRequest, ) -> anyhow::Result> { let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else { @@ -428,8 +447,7 @@ fn get_authenticated_user_info( let cookie_value = cookie.value().to_string(); let state = get_state_from_cookie(request)?; - let verifier: openidconnect::IdTokenVerifier<'_, openidconnect::core::CoreJsonWebKey> = - oidc_client.id_token_verifier(); + let verifier = config.create_id_token_verifier(oidc_client); let id_token = OidcToken::from_str(&cookie_value) .with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?; @@ -668,7 +686,7 @@ impl OidcLoginState { } } -fn create_state_cookie(request: &ServiceRequest, auth_url: AuthUrlParams) -> Cookie<'_> { +fn create_state_cookie(request: &ServiceRequest, auth_url: AuthUrlParams) -> Cookie { let state = OidcLoginState::new(request, auth_url); let state_json = serde_json::to_string(&state).unwrap(); Cookie::build(SQLPAGE_STATE_COOKIE_NAME, state_json) @@ -687,6 +705,29 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result>); + +impl AudienceVerifier { + /// JWT audiences (aud claim) are always required to contain the `client_id`, but they can also contain additional audiences. + /// By default we allow any additional audience. + /// The user can restrict the allowed additional audiences by providing a list of trusted audiences. + fn new(additional_trusted_audiences: Option>) -> Self { + AudienceVerifier(additional_trusted_audiences.map(HashSet::from_iter)) + } + + /// Returns a function that given an audience, verifies if it is trusted. + fn as_fn(&self) -> impl Fn(&Audience) -> bool + '_ { + move |aud: &Audience| -> bool { + let Some(trusted_set) = &self.0 else { + return true; + }; + trusted_set.contains(aud.as_str()) + } + } +} + /// Validate that a redirect URL is safe to use (prevents open redirect attacks) fn validate_redirect_url(url: String) -> String { if url.starts_with('/') && !url.starts_with("//") {