From 887e2d51a400c8383394221fa4c93e177ff89288 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Jul 2025 23:06:43 +0000 Subject: [PATCH 1/6] Add OIDC multiple audiences support with configurable trust settings Co-authored-by: contact --- .../sqlpage/migrations/61_oidc_functions.sql | 16 +++ examples/single sign on/README.md | 23 ++++ .../sqlpage/multiple-audiences-example.yaml | 32 +++++ src/app_config.rs | 8 ++ src/webserver/oidc.rs | 112 ++++++++++++++++-- 5 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 examples/single sign on/sqlpage/multiple-audiences-example.yaml diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql index eda1c2cc..bf533549 100644 --- a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql +++ b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql @@ -151,6 +151,22 @@ If you''re not getting the information you expect: 4. Check your OIDC provider''s documentation for the exact claim names they use Remember: If the user is not logged in or the requested information is not available, this function returns NULL. + +## Handling Multiple Audiences + +Some OpenID Connect providers (like ZITADEL, Azure AD, etc.) include multiple audience values in their JWT tokens. +SQLPage handles this automatically by default, allowing any additional audiences as long as your client ID is present in the audience list. + +If you need to restrict which additional audiences are trusted, you can configure this in your `sqlpage.json`: + +```json +{ + "oidc_additional_trusted_audiences": ["api.example.com", "service.example.com"] +} +``` + +Set `oidc_additional_trusted_audiences` to an empty array `[]` to only allow your client ID as the audience (strictest security). +Leave it unset (default) to allow any additional audiences (maximum compatibility). ' ); diff --git a/examples/single sign on/README.md b/examples/single sign on/README.md index c947d363..79ded93b 100644 --- a/examples/single sign on/README.md +++ b/examples/single sign on/README.md @@ -74,6 +74,29 @@ The configuration parameters are: - `oidc_client_secret`: The secret key for your SQLPage application - `host`: The web address where your application is accessible +#### Handling Multiple Audiences + +Some OIDC providers (like ZITADEL, Azure AD, etc.) include multiple audience values in their JWT tokens. SQLPage handles this automatically by default, allowing any additional audiences as long as your client ID is present in the audience list. + +If you need more control over which audiences are trusted, you can configure this: + +```json +{ + "oidc_issuer_url": "https://your-provider.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080", + "oidc_additional_trusted_audiences": ["api.yourapp.com", "service.yourapp.com"] +} +``` + +Audience configuration options: +- **Not set (default)**: Allow any additional audiences for maximum compatibility +- **Empty array `[]`**: Only allow your client ID as audience (strictest security) +- **Specific list**: Only allow the listed additional audiences plus your client ID + +This is particularly useful for enterprise environments where tokens may contain multiple services as audiences. + ### Accessing User Information Once OIDC is configured, you can access information about the authenticated user in your SQL files using these functions: diff --git a/examples/single sign on/sqlpage/multiple-audiences-example.yaml b/examples/single sign on/sqlpage/multiple-audiences-example.yaml new file mode 100644 index 00000000..f04f956d --- /dev/null +++ b/examples/single sign on/sqlpage/multiple-audiences-example.yaml @@ -0,0 +1,32 @@ +# Example SQLPage configuration for providers with multiple audiences +# This configuration shows how to handle OIDC providers like ZITADEL, Azure AD, etc. +# that include multiple audience values in their JWT tokens. + +oidc_issuer_url: https://your-provider.example.com +oidc_client_id: sqlpage +oidc_client_secret: your-client-secret-here + +# Option 1: Default behavior (comment out or omit this line) +# By default, SQLPage allows any additional audiences for maximum compatibility +# This works with most providers that add multiple audiences +# oidc_additional_trusted_audiences: + +# Option 2: Strict mode - only allow client ID as audience +# Uncomment the line below to only allow your client ID in the audience +# oidc_additional_trusted_audiences: [] + +# Option 3: Specific trusted audiences +# Only allow specific additional audiences beyond your client ID +oidc_additional_trusted_audiences: + - api.yourapp.com + - service.yourapp.com + - mobile.yourapp.com + +# Standard path protection configuration +oidc_protected_paths: + - /protected +oidc_public_paths: + - /protected/public + +# Host configuration for redirect URLs +host: localhost:8080 \ No newline at end of file diff --git a/src/app_config.rs b/src/app_config.rs index 6b2ef78d..aab4d5e4 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 d4e53ef0..57db82ad 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -17,7 +17,7 @@ use chrono::Utc; use openidconnect::{ core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, - TokenResponse, + TokenResponse, Audience, }; use serde::{Deserialize, Serialize}; @@ -52,6 +52,11 @@ pub struct OidcConfig { pub public_paths: Vec, pub app_host: String, pub scopes: Vec, + /// Additional trusted audiences beyond the client ID. + /// By default, any additional audiences are trusted for compatibility with providers + /// that include multiple audience values (like ZITADEL, Azure AD, etc.). + /// Set to Some(vec![]) to only allow the client ID as audience. + pub additional_trusted_audiences: Option>, } impl TryFrom<&AppConfig> for OidcConfig { @@ -79,6 +84,7 @@ impl TryFrom<&AppConfig> for OidcConfig { .map(|s| Scope::new(s.to_string())) .collect(), app_host: app_host.clone(), + additional_trusted_audiences: config.oidc_additional_trusted_audiences.clone(), }) } } @@ -241,7 +247,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}"); @@ -269,7 +275,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)) => { log::trace!("Storing authenticated user info in request extensions: {claims:?}"); request.extensions_mut().insert(claims); @@ -299,6 +306,7 @@ where async fn process_oidc_callback( oidc_client: &OidcClient, + oidc_config: &Arc, query_string: &str, request: &ServiceRequest, ) -> anyhow::Result { @@ -324,7 +332,7 @@ async fn process_oidc_callback( log::debug!("Received token response: {token_response:?}"); let mut response = build_redirect_response(state.initial_url); - set_auth_cookie(&mut response, &token_response, oidc_client)?; + set_auth_cookie(&mut response, &token_response, oidc_client, oidc_config)?; Ok(response) } @@ -346,6 +354,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()); @@ -353,7 +362,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 = create_custom_id_token_verifier(oidc_client, oidc_config); 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(); @@ -402,6 +411,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 { @@ -410,8 +420,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 = create_custom_id_token_verifier(oidc_client, config); let id_token = OidcToken::from_str(&cookie_value) .with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?; @@ -668,3 +677,92 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result( + oidc_client: &'a OidcClient, + config: &OidcConfig, +) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> { + let client_id = config.client_id.clone(); + let additional_trusted_audiences = config.additional_trusted_audiences.clone(); + + oidc_client + .id_token_verifier() + .set_other_audience_verifier_fn(move |aud: &Audience| -> bool { + let aud_str = aud.as_str(); + + // Always allow the client ID itself as an audience + if aud_str == client_id { + return true; + } + + match &additional_trusted_audiences { + // Default behavior: allow all additional audiences for compatibility + None => true, + // Specific list: only allow audiences in the list + Some(trusted_list) => trusted_list.contains(&aud_str.to_string()), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audience_verification_logic() { + let client_id = "test-client-123"; + + // Test 1: Default behavior (None) - should allow any additional audiences + let config = OidcConfig { + issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), + client_id: client_id.to_string(), + client_secret: "secret".to_string(), + protected_paths: vec![], + public_paths: vec![], + app_host: "localhost".to_string(), + scopes: vec![], + additional_trusted_audiences: None, + }; + + // Test the logic that would be used in set_other_audience_verifier_fn + let verifier_fn = |aud_str: &str, config: &OidcConfig| -> bool { + // Always allow the client ID itself as an audience + if aud_str == config.client_id { + return true; + } + + match &config.additional_trusted_audiences { + // Default behavior: allow all additional audiences for compatibility + None => true, + // Specific list: only allow audiences in the list + Some(trusted_list) => trusted_list.contains(&aud_str.to_string()), + } + }; + + // Test with default config (should allow additional audiences) + assert!(verifier_fn(client_id, &config)); // Client ID should be allowed + assert!(verifier_fn("some-other-audience", &config)); // Additional audience should be allowed + + // Test 2: Empty list (strictest) - only allow client ID + let mut strict_config = config.clone(); + strict_config.additional_trusted_audiences = Some(vec![]); + + assert!(verifier_fn(client_id, &strict_config)); // Client ID should be allowed + assert!(!verifier_fn("some-other-audience", &strict_config)); // Additional audience should be rejected + + // Test 3: Specific allowed audiences + let mut specific_config = config.clone(); + specific_config.additional_trusted_audiences = Some(vec![ + "api.example.com".to_string(), + "service.example.com".to_string(), + ]); + + assert!(verifier_fn(client_id, &specific_config)); // Client ID should be allowed + assert!(verifier_fn("api.example.com", &specific_config)); // Listed audience should be allowed + assert!(verifier_fn("service.example.com", &specific_config)); // Listed audience should be allowed + assert!(!verifier_fn("untrusted.example.com", &specific_config)); // Unlisted audience should be rejected + } +} From 05778f1248dc2a87d5b8e2f71c4443da773b437e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 28 Jul 2025 23:13:58 +0000 Subject: [PATCH 2/6] Refactor OIDC audience verification with improved configuration options Co-authored-by: contact --- src/app_config.rs | 2 +- src/webserver/oidc.rs | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index aab4d5e4..bcf509be 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -224,7 +224,7 @@ pub struct AppConfig { 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 + /// 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. diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 57db82ad..da9d6526 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -15,9 +15,9 @@ 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, Audience, + TokenResponse, }; use serde::{Deserialize, Serialize}; @@ -687,17 +687,17 @@ fn create_custom_id_token_verifier<'a>( ) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> { let client_id = config.client_id.clone(); let additional_trusted_audiences = config.additional_trusted_audiences.clone(); - + oidc_client .id_token_verifier() .set_other_audience_verifier_fn(move |aud: &Audience| -> bool { let aud_str = aud.as_str(); - + // Always allow the client ID itself as an audience if aud_str == client_id { return true; } - + match &additional_trusted_audiences { // Default behavior: allow all additional audiences for compatibility None => true, @@ -714,7 +714,7 @@ mod tests { #[test] fn test_audience_verification_logic() { let client_id = "test-client-123"; - + // Test 1: Default behavior (None) - should allow any additional audiences let config = OidcConfig { issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), @@ -726,14 +726,14 @@ mod tests { scopes: vec![], additional_trusted_audiences: None, }; - + // Test the logic that would be used in set_other_audience_verifier_fn let verifier_fn = |aud_str: &str, config: &OidcConfig| -> bool { // Always allow the client ID itself as an audience if aud_str == config.client_id { return true; } - + match &config.additional_trusted_audiences { // Default behavior: allow all additional audiences for compatibility None => true, @@ -749,7 +749,7 @@ mod tests { // Test 2: Empty list (strictest) - only allow client ID let mut strict_config = config.clone(); strict_config.additional_trusted_audiences = Some(vec![]); - + assert!(verifier_fn(client_id, &strict_config)); // Client ID should be allowed assert!(!verifier_fn("some-other-audience", &strict_config)); // Additional audience should be rejected @@ -759,7 +759,7 @@ mod tests { "api.example.com".to_string(), "service.example.com".to_string(), ]); - + assert!(verifier_fn(client_id, &specific_config)); // Client ID should be allowed assert!(verifier_fn("api.example.com", &specific_config)); // Listed audience should be allowed assert!(verifier_fn("service.example.com", &specific_config)); // Listed audience should be allowed From 5a751ffd6d1f51669b79ca8a45188eaa6dd07e7f Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 29 Jul 2025 22:59:26 +0200 Subject: [PATCH 3/6] remive verbose docs --- .../sqlpage/migrations/61_oidc_functions.sql | 16 ---------- examples/single sign on/README.md | 23 ------------- .../sqlpage/multiple-audiences-example.yaml | 32 ------------------- 3 files changed, 71 deletions(-) delete mode 100644 examples/single sign on/sqlpage/multiple-audiences-example.yaml diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql index bf533549..eda1c2cc 100644 --- a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql +++ b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql @@ -151,22 +151,6 @@ If you''re not getting the information you expect: 4. Check your OIDC provider''s documentation for the exact claim names they use Remember: If the user is not logged in or the requested information is not available, this function returns NULL. - -## Handling Multiple Audiences - -Some OpenID Connect providers (like ZITADEL, Azure AD, etc.) include multiple audience values in their JWT tokens. -SQLPage handles this automatically by default, allowing any additional audiences as long as your client ID is present in the audience list. - -If you need to restrict which additional audiences are trusted, you can configure this in your `sqlpage.json`: - -```json -{ - "oidc_additional_trusted_audiences": ["api.example.com", "service.example.com"] -} -``` - -Set `oidc_additional_trusted_audiences` to an empty array `[]` to only allow your client ID as the audience (strictest security). -Leave it unset (default) to allow any additional audiences (maximum compatibility). ' ); diff --git a/examples/single sign on/README.md b/examples/single sign on/README.md index 79ded93b..c947d363 100644 --- a/examples/single sign on/README.md +++ b/examples/single sign on/README.md @@ -74,29 +74,6 @@ The configuration parameters are: - `oidc_client_secret`: The secret key for your SQLPage application - `host`: The web address where your application is accessible -#### Handling Multiple Audiences - -Some OIDC providers (like ZITADEL, Azure AD, etc.) include multiple audience values in their JWT tokens. SQLPage handles this automatically by default, allowing any additional audiences as long as your client ID is present in the audience list. - -If you need more control over which audiences are trusted, you can configure this: - -```json -{ - "oidc_issuer_url": "https://your-provider.com", - "oidc_client_id": "your-client-id", - "oidc_client_secret": "your-client-secret", - "host": "localhost:8080", - "oidc_additional_trusted_audiences": ["api.yourapp.com", "service.yourapp.com"] -} -``` - -Audience configuration options: -- **Not set (default)**: Allow any additional audiences for maximum compatibility -- **Empty array `[]`**: Only allow your client ID as audience (strictest security) -- **Specific list**: Only allow the listed additional audiences plus your client ID - -This is particularly useful for enterprise environments where tokens may contain multiple services as audiences. - ### Accessing User Information Once OIDC is configured, you can access information about the authenticated user in your SQL files using these functions: diff --git a/examples/single sign on/sqlpage/multiple-audiences-example.yaml b/examples/single sign on/sqlpage/multiple-audiences-example.yaml deleted file mode 100644 index f04f956d..00000000 --- a/examples/single sign on/sqlpage/multiple-audiences-example.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Example SQLPage configuration for providers with multiple audiences -# This configuration shows how to handle OIDC providers like ZITADEL, Azure AD, etc. -# that include multiple audience values in their JWT tokens. - -oidc_issuer_url: https://your-provider.example.com -oidc_client_id: sqlpage -oidc_client_secret: your-client-secret-here - -# Option 1: Default behavior (comment out or omit this line) -# By default, SQLPage allows any additional audiences for maximum compatibility -# This works with most providers that add multiple audiences -# oidc_additional_trusted_audiences: - -# Option 2: Strict mode - only allow client ID as audience -# Uncomment the line below to only allow your client ID in the audience -# oidc_additional_trusted_audiences: [] - -# Option 3: Specific trusted audiences -# Only allow specific additional audiences beyond your client ID -oidc_additional_trusted_audiences: - - api.yourapp.com - - service.yourapp.com - - mobile.yourapp.com - -# Standard path protection configuration -oidc_protected_paths: - - /protected -oidc_public_paths: - - /protected/public - -# Host configuration for redirect URLs -host: localhost:8080 \ No newline at end of file From 307243b01040b214470e1b210a064290837deb41 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 30 Jul 2025 12:00:26 +0200 Subject: [PATCH 4/6] Refactor OIDC audience verification logic The changes move audience verification into a dedicated type and improve code organization around ID token verification. --- src/webserver/oidc.rs | 127 ++++++++++++------------------------------ 1 file changed, 35 insertions(+), 92 deletions(-) diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index da9d6526..a1606eed 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}; @@ -52,11 +53,7 @@ pub struct OidcConfig { pub public_paths: Vec, pub app_host: String, pub scopes: Vec, - /// Additional trusted audiences beyond the client ID. - /// By default, any additional audiences are trusted for compatibility with providers - /// that include multiple audience values (like ZITADEL, Azure AD, etc.). - /// Set to Some(vec![]) to only allow the client ID as audience. - pub additional_trusted_audiences: Option>, + pub additional_audience_verifier: AudienceVerifier, } impl TryFrom<&AppConfig> for OidcConfig { @@ -84,7 +81,9 @@ impl TryFrom<&AppConfig> for OidcConfig { .map(|s| Scope::new(s.to_string())) .collect(), app_host: app_host.clone(), - additional_trusted_audiences: config.oidc_additional_trusted_audiences.clone(), + additional_audience_verifier: AudienceVerifier::new( + config.oidc_additional_trusted_audiences.clone(), + ), }) } } @@ -95,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 { @@ -362,7 +371,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 = create_custom_id_token_verifier(oidc_client, oidc_config); + 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,7 +429,7 @@ fn get_authenticated_user_info( let cookie_value = cookie.value().to_string(); let state = get_state_from_cookie(request)?; - let verifier = create_custom_id_token_verifier(oidc_client, config); + 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:?}"))?; @@ -678,91 +687,25 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result( - oidc_client: &'a OidcClient, - config: &OidcConfig, -) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> { - let client_id = config.client_id.clone(); - let additional_trusted_audiences = config.additional_trusted_audiences.clone(); - - oidc_client - .id_token_verifier() - .set_other_audience_verifier_fn(move |aud: &Audience| -> bool { - let aud_str = aud.as_str(); - - // Always allow the client ID itself as an audience - if aud_str == client_id { - return true; - } - - match &additional_trusted_audiences { - // Default behavior: allow all additional audiences for compatibility - None => true, - // Specific list: only allow audiences in the list - Some(trusted_list) => trusted_list.contains(&aud_str.to_string()), - } - }) -} +/// Given an audience, verify if it is trusted. The `client_id` is always trusted, independently of this function. +#[derive(Clone, Debug)] +pub struct AudienceVerifier(Option>); + +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)) + } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_audience_verification_logic() { - let client_id = "test-client-123"; - - // Test 1: Default behavior (None) - should allow any additional audiences - let config = OidcConfig { - issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(), - client_id: client_id.to_string(), - client_secret: "secret".to_string(), - protected_paths: vec![], - public_paths: vec![], - app_host: "localhost".to_string(), - scopes: vec![], - additional_trusted_audiences: None, - }; - - // Test the logic that would be used in set_other_audience_verifier_fn - let verifier_fn = |aud_str: &str, config: &OidcConfig| -> bool { - // Always allow the client ID itself as an audience - if aud_str == config.client_id { + /// 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; - } - - match &config.additional_trusted_audiences { - // Default behavior: allow all additional audiences for compatibility - None => true, - // Specific list: only allow audiences in the list - Some(trusted_list) => trusted_list.contains(&aud_str.to_string()), - } - }; - - // Test with default config (should allow additional audiences) - assert!(verifier_fn(client_id, &config)); // Client ID should be allowed - assert!(verifier_fn("some-other-audience", &config)); // Additional audience should be allowed - - // Test 2: Empty list (strictest) - only allow client ID - let mut strict_config = config.clone(); - strict_config.additional_trusted_audiences = Some(vec![]); - - assert!(verifier_fn(client_id, &strict_config)); // Client ID should be allowed - assert!(!verifier_fn("some-other-audience", &strict_config)); // Additional audience should be rejected - - // Test 3: Specific allowed audiences - let mut specific_config = config.clone(); - specific_config.additional_trusted_audiences = Some(vec![ - "api.example.com".to_string(), - "service.example.com".to_string(), - ]); - - assert!(verifier_fn(client_id, &specific_config)); // Client ID should be allowed - assert!(verifier_fn("api.example.com", &specific_config)); // Listed audience should be allowed - assert!(verifier_fn("service.example.com", &specific_config)); // Listed audience should be allowed - assert!(!verifier_fn("untrusted.example.com", &specific_config)); // Unlisted audience should be rejected + }; + trusted_set.contains(aud.as_str()) + } } } From f91fc346ad4c24529512b3d38d33c9cd072a2022 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 30 Jul 2025 16:58:39 +0200 Subject: [PATCH 5/6] Use oidc_additional_trusted_audiences in sso example Add OIDC config comments and improve array syntax --- examples/single sign on/sqlpage/sqlpage.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 From e7c1e94045cc7808113c19efaa068103011ccd1c Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 30 Jul 2025 17:06:17 +0200 Subject: [PATCH 6/6] document oidc_additional_trusted_audiences --- configuration.md | 1 + 1 file changed, 1 insertion(+) 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. |