Skip to content

Support multiple jwt audiences for oidc #977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 30, 2025
Merged
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
1 change: 1 addition & 0 deletions configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
11 changes: 5 additions & 6 deletions examples/single sign on/sqlpage/sqlpage.yaml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ pub struct AppConfig {
#[serde(default)]
pub oidc_public_paths: Vec<String>,

/// 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<Vec<String>>,

/// 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.
Expand Down
57 changes: 49 additions & 8 deletions src/webserver/oidc.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::future::ready;
use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};

Expand All @@ -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,
};
Expand Down Expand Up @@ -52,6 +53,7 @@ pub struct OidcConfig {
pub public_paths: Vec<String>,
pub app_host: String,
pub scopes: Vec<Scope>,
pub additional_audience_verifier: AudienceVerifier,
}

impl TryFrom<&AppConfig> for OidcConfig {
Expand Down Expand Up @@ -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(),
),
})
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -315,6 +331,7 @@ where

async fn process_oidc_callback(
oidc_client: &OidcClient,
oidc_config: &Arc<OidcConfig>,
query_string: &str,
request: &ServiceRequest,
) -> anyhow::Result<HttpResponse> {
Expand Down Expand Up @@ -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)
}

Expand All @@ -364,14 +381,15 @@ fn set_auth_cookie(
response: &mut HttpResponse,
token_response: &openidconnect::core::CoreTokenResponse,
oidc_client: &OidcClient,
oidc_config: &Arc<OidcConfig>,
) -> anyhow::Result<()> {
let access_token = token_response.access_token();
log::trace!("Received access token: {}", access_token.secret());
let id_token = token_response
.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();
Expand Down Expand Up @@ -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<OidcConfig>,
request: &ServiceRequest,
) -> anyhow::Result<Option<OidcClaims>> {
let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else {
Expand All @@ -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:?}"))?;

Expand Down Expand Up @@ -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)
Expand All @@ -687,6 +705,29 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result<OidcLoginSt
.with_context(|| format!("Failed to parse OIDC state from cookie: {state_cookie}"))
}

/// 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<HashSet<String>>);

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<Vec<String>>) -> 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("//") {
Expand Down