Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions examples/official-site/sqlpage/migrations/61_oidc_functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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).
'
);

Expand Down
23 changes: 23 additions & 0 deletions examples/single sign on/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions examples/single sign on/sqlpage/multiple-audiences-example.yaml
Original file line number Diff line number Diff line change
@@ -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
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
112 changes: 105 additions & 7 deletions src/webserver/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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 +52,11 @@ pub struct OidcConfig {
pub public_paths: Vec<String>,
pub app_host: String,
pub scopes: Vec<Scope>,
/// 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<Vec<String>>,
}

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

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

Expand All @@ -346,14 +354,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 = 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();
Expand Down Expand Up @@ -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<OidcConfig>,
request: &ServiceRequest,
) -> anyhow::Result<Option<OidcClaims>> {
let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else {
Expand All @@ -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:?}"))?;

Expand Down Expand Up @@ -668,3 +677,92 @@ fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result<OidcLoginSt
serde_json::from_str(state_cookie.value())
.with_context(|| format!("Failed to parse OIDC state from cookie: {state_cookie}"))
}

/// Creates an ID token verifier with custom audience validation that supports multiple audiences.
/// By default, allows any additional audiences for compatibility with providers like ZITADEL.
/// Only requires that the client ID is present in the audience list.
fn create_custom_id_token_verifier<'a>(
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
}
}