Skip to content

Commit 2a13f62

Browse files
lovasoacursoragent
andauthored
Support multiple jwt audiences for oidc (#977)
* Add OIDC multiple audiences support with configurable trust settings Co-authored-by: contact <[email protected]> * Refactor OIDC audience verification with improved configuration options Co-authored-by: contact <[email protected]> * remive verbose docs * Refactor OIDC audience verification logic The changes move audience verification into a dedicated type and improve code organization around ID token verification. * Use oidc_additional_trusted_audiences in sso example Add OIDC config comments and improve array syntax * document oidc_additional_trusted_audiences --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 558b578 commit 2a13f62

File tree

4 files changed

+63
-14
lines changed

4 files changed

+63
-14
lines changed

configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Here are the available configuration options and their default values:
3131
| `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. |
3232
| `oidc_client_secret` | | The secret key for your SQLPage application. Keep this confidential as it allows your app to authenticate with the OIDC provider. |
3333
| `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. |
34+
| `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. |
3435
| `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. |
3536
| `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). |
3637
| `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. |
Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo
2-
oidc_client_id: sqlpage
1+
oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo # Given by keycloak as the "OpenID Endpoint Configuration" url.
2+
oidc_client_id: sqlpage # configured in keycloak (http://localhost:8181/admin/master/console/#/sqlpage_demo/clients/a2bec2b8-f850-405e-9f26-59063ffa6f08/settings)
33
oidc_client_secret: qiawfnYrYzsmoaOZT28rRjPPRamfvrYr # For a safer setup, use environment variables to store this
4-
oidc_protected_paths:
5-
- /protected # Makes the website root is publicly accessible, requiring authentication only for the /protected path
6-
oidc_public_paths:
7-
- /protected/public # Adds an exception for the /protected/public path, which is publicly accessible too
4+
oidc_protected_paths: ["/protected"] # Makes the website root is publicly accessible, requiring authentication only for the /protected path
5+
oidc_public_paths: ["/protected/public"] # Adds an exception for the /protected/public path, which is publicly accessible too
6+
oidc_additional_trusted_audiences: [] # For increased security, reject any token that has more than just the client ID in the "aud" claim

src/app_config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@ pub struct AppConfig {
223223
#[serde(default)]
224224
pub oidc_public_paths: Vec<String>,
225225

226+
/// Additional trusted audiences for OIDC JWT tokens, beyond the client ID.
227+
/// By default (when None), all additional audiences are trusted for compatibility
228+
/// with providers that include multiple audience values (like ZITADEL, Azure AD, etc.).
229+
/// Set to an empty list to only allow the client ID as audience.
230+
/// Set to a specific list to only allow those specific additional audiences.
231+
#[serde(default)]
232+
pub oidc_additional_trusted_audiences: Option<Vec<String>>,
233+
226234
/// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary
227235
/// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the
228236
/// server's IP address.

src/webserver/oidc.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashSet;
12
use std::future::ready;
23
use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};
34

@@ -15,7 +16,7 @@ use anyhow::{anyhow, Context};
1516
use awc::Client;
1617
use chrono::Utc;
1718
use openidconnect::{
18-
core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, CsrfToken, EndpointMaybeSet,
19+
core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet,
1920
EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope,
2021
TokenResponse,
2122
};
@@ -52,6 +53,7 @@ pub struct OidcConfig {
5253
pub public_paths: Vec<String>,
5354
pub app_host: String,
5455
pub scopes: Vec<Scope>,
56+
pub additional_audience_verifier: AudienceVerifier,
5557
}
5658

5759
impl TryFrom<&AppConfig> for OidcConfig {
@@ -79,6 +81,9 @@ impl TryFrom<&AppConfig> for OidcConfig {
7981
.map(|s| Scope::new(s.to_string()))
8082
.collect(),
8183
app_host: app_host.clone(),
84+
additional_audience_verifier: AudienceVerifier::new(
85+
config.oidc_additional_trusted_audiences.clone(),
86+
),
8287
})
8388
}
8489
}
@@ -89,6 +94,16 @@ impl OidcConfig {
8994
!self.protected_paths.iter().any(|p| path.starts_with(p))
9095
|| self.public_paths.iter().any(|p| path.starts_with(p))
9196
}
97+
98+
/// Creates a custom ID token verifier that supports multiple issuers
99+
fn create_id_token_verifier<'a>(
100+
&'a self,
101+
oidc_client: &'a OidcClient,
102+
) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> {
103+
oidc_client
104+
.id_token_verifier()
105+
.set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn())
106+
}
92107
}
93108

94109
fn get_app_host(config: &AppConfig) -> String {
@@ -241,7 +256,7 @@ where
241256

242257
Box::pin(async move {
243258
let query_string = request.query_string();
244-
match process_oidc_callback(&oidc_client, query_string, &request).await {
259+
match process_oidc_callback(&oidc_client, &oidc_config, query_string, &request).await {
245260
Ok(response) => Ok(request.into_response(response)),
246261
Err(e) => {
247262
log::error!("Failed to process OIDC callback with params {query_string}: {e}");
@@ -282,7 +297,8 @@ where
282297
log::trace!("Started OIDC middleware request handling");
283298

284299
let oidc_client = Arc::clone(&self.oidc_state.client);
285-
match get_authenticated_user_info(&oidc_client, &request) {
300+
let oidc_config = Arc::clone(&self.oidc_state.config);
301+
match get_authenticated_user_info(&oidc_client, &oidc_config, &request) {
286302
Ok(Some(claims)) => {
287303
if request.path() == SQLPAGE_REDIRECT_URI {
288304
return handle_authenticated_oidc_callback(request);
@@ -315,6 +331,7 @@ where
315331

316332
async fn process_oidc_callback(
317333
oidc_client: &OidcClient,
334+
oidc_config: &Arc<OidcConfig>,
318335
query_string: &str,
319336
request: &ServiceRequest,
320337
) -> anyhow::Result<HttpResponse> {
@@ -342,7 +359,7 @@ async fn process_oidc_callback(
342359
let redirect_target = validate_redirect_url(state.initial_url);
343360
log::info!("Redirecting to {redirect_target} after a successful login");
344361
let mut response = build_redirect_response(redirect_target);
345-
set_auth_cookie(&mut response, &token_response, oidc_client)?;
362+
set_auth_cookie(&mut response, &token_response, oidc_client, oidc_config)?;
346363
Ok(response)
347364
}
348365

@@ -364,14 +381,15 @@ fn set_auth_cookie(
364381
response: &mut HttpResponse,
365382
token_response: &openidconnect::core::CoreTokenResponse,
366383
oidc_client: &OidcClient,
384+
oidc_config: &Arc<OidcConfig>,
367385
) -> anyhow::Result<()> {
368386
let access_token = token_response.access_token();
369387
log::trace!("Received access token: {}", access_token.secret());
370388
let id_token = token_response
371389
.id_token()
372390
.context("No ID token found in the token response. You may have specified an oauth2 provider that does not support OIDC.")?;
373391

374-
let id_token_verifier = oidc_client.id_token_verifier();
392+
let id_token_verifier = oidc_config.create_id_token_verifier(oidc_client);
375393
let nonce_verifier = |_nonce: Option<&Nonce>| Ok(()); // The nonce will be verified in request handling
376394
let claims = id_token.claims(&id_token_verifier, nonce_verifier)?;
377395
let expiration = claims.expiration();
@@ -420,6 +438,7 @@ fn build_redirect_response(target_url: String) -> HttpResponse {
420438
/// Returns the claims from the ID token in the `SQLPage` auth cookie.
421439
fn get_authenticated_user_info(
422440
oidc_client: &OidcClient,
441+
config: &Arc<OidcConfig>,
423442
request: &ServiceRequest,
424443
) -> anyhow::Result<Option<OidcClaims>> {
425444
let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else {
@@ -428,8 +447,7 @@ fn get_authenticated_user_info(
428447
let cookie_value = cookie.value().to_string();
429448

430449
let state = get_state_from_cookie(request)?;
431-
let verifier: openidconnect::IdTokenVerifier<'_, openidconnect::core::CoreJsonWebKey> =
432-
oidc_client.id_token_verifier();
450+
let verifier = config.create_id_token_verifier(oidc_client);
433451
let id_token = OidcToken::from_str(&cookie_value)
434452
.with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?;
435453

@@ -668,7 +686,7 @@ impl OidcLoginState {
668686
}
669687
}
670688

671-
fn create_state_cookie(request: &ServiceRequest, auth_url: AuthUrlParams) -> Cookie<'_> {
689+
fn create_state_cookie(request: &ServiceRequest, auth_url: AuthUrlParams) -> Cookie {
672690
let state = OidcLoginState::new(request, auth_url);
673691
let state_json = serde_json::to_string(&state).unwrap();
674692
Cookie::build(SQLPAGE_STATE_COOKIE_NAME, state_json)
@@ -687,6 +705,29 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result<OidcLoginSt
687705
.with_context(|| format!("Failed to parse OIDC state from cookie: {state_cookie}"))
688706
}
689707

708+
/// Given an audience, verify if it is trusted. The `client_id` is always trusted, independently of this function.
709+
#[derive(Clone, Debug)]
710+
pub struct AudienceVerifier(Option<HashSet<String>>);
711+
712+
impl AudienceVerifier {
713+
/// JWT audiences (aud claim) are always required to contain the `client_id`, but they can also contain additional audiences.
714+
/// By default we allow any additional audience.
715+
/// The user can restrict the allowed additional audiences by providing a list of trusted audiences.
716+
fn new(additional_trusted_audiences: Option<Vec<String>>) -> Self {
717+
AudienceVerifier(additional_trusted_audiences.map(HashSet::from_iter))
718+
}
719+
720+
/// Returns a function that given an audience, verifies if it is trusted.
721+
fn as_fn(&self) -> impl Fn(&Audience) -> bool + '_ {
722+
move |aud: &Audience| -> bool {
723+
let Some(trusted_set) = &self.0 else {
724+
return true;
725+
};
726+
trusted_set.contains(aud.as_str())
727+
}
728+
}
729+
}
730+
690731
/// Validate that a redirect URL is safe to use (prevents open redirect attacks)
691732
fn validate_redirect_url(url: String) -> String {
692733
if url.starts_with('/') && !url.starts_with("//") {

0 commit comments

Comments
 (0)