Skip to content

Single Sign-On via OIDC #888

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

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
95c37c7
add oidc config variables
lovasoa Apr 21, 2025
6f58587
setup a basic middleware
lovasoa Apr 22, 2025
c21f89a
implement an async http client that uses oidc
lovasoa Apr 23, 2025
661c6d6
initialize provider_metadata in OidcService
lovasoa Apr 23, 2025
5e54aab
better error handling in oidc config
lovasoa Apr 23, 2025
845b39b
HTTP client initialization in oidc now follows global config
lovasoa Apr 23, 2025
67d2979
Merge branch 'main' into oidc
lovasoa Apr 25, 2025
d5fd554
oidc: implement redirects
lovasoa Apr 26, 2025
32bda97
improve local oidc configurability
lovasoa Apr 26, 2025
e193fdf
log
lovasoa Apr 26, 2025
ef9dd31
Update warning message in OIDC configuration to clarify how to disabl…
lovasoa Apr 26, 2025
08c97f6
Update OIDC redirect logging to use info level with client ID
lovasoa Apr 26, 2025
5e8b12d
Refactor unauthenticated request handling in OIDC service
lovasoa Apr 26, 2025
d70fac3
Enhance OIDC service with callback handling and token processing
lovasoa Apr 27, 2025
a5cb479
in handle_oidc_callback use service_request.into_response
lovasoa Apr 27, 2025
938f51c
fmt
lovasoa Apr 27, 2025
e305b89
Implement oidc code exchange and token storage
lovasoa Apr 27, 2025
9f22532
validate oidc cookies
lovasoa Apr 27, 2025
8e07bab
OIDC callback: redirect to the auth URL on failure.
lovasoa Apr 27, 2025
9f302d9
oidc use localhost for redirect config instead of 0.0.0.0 by default
lovasoa Apr 27, 2025
5919008
Enhance OIDC provider metadata discovery with improved logging and er…
lovasoa Apr 27, 2025
55bf296
maintain the initial URL during OIDC authentication
lovasoa Apr 27, 2025
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
345 changes: 341 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ rustls-native-certs = "0.7.0"
awc = { version = "3", features = ["rustls-0_22-webpki-roots"] }
clap = { version = "4.5.17", features = ["derive"] }
tokio-util = "0.7.12"
openidconnect = { version = "4.0.0", default-features = false }

[build-dependencies]
awc = { version = "3", features = ["rustls-0_22-webpki-roots"] }
Expand Down
59 changes: 59 additions & 0 deletions configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Here are the available configuration options and their default values:
| `database_password` | | Database password. If set, this will override any password specified in the `database_url`. This allows you to keep the password separate from the connection string for better security. |
| `port` | 8080 | Like listen_on, but specifies only the port. |
| `unix_socket` | | Path to a UNIX socket to listen on instead of the TCP port. If specified, SQLPage will accept HTTP connections only on this socket and not on any TCP port. This option is mutually exclusive with `listen_on` and `port`.
| `host` | | The web address where your application is accessible (e.g., "myapp.example.com"). Used for login redirects with OIDC. |
| `max_database_pool_connections` | PostgreSQL: 50<BR> MySql: 75<BR> SQLite: 16<BR> MSSQL: 100 | How many simultaneous database connections to open at most |
| `database_connection_idle_timeout_seconds` | SQLite: None<BR> All other: 30 minutes | Automatically close database connections after this period of inactivity |
| `database_connection_max_lifetime_seconds` | SQLite: None<BR> All other: 60 minutes | Always close database connections after this amount of time |
Expand All @@ -24,6 +25,10 @@ Here are the available configuration options and their default values:
| `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql-page.com/custom_components.sql), [`migrations/`](https://sql-page.com/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT |
| `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. |
| `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. |
| `oidc_issuer_url` | | The base URL of the [OpenID Connect provider](#openid-connect-oidc-authentication). Required for enabling Single Sign-On. |
| `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. |
| `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 Expand Up @@ -83,6 +88,60 @@ If the `database_password` configuration parameter is set, it will override any
It does not need to be percent-encoded.
This allows you to keep the password separate from the connection string, which can be useful for security purposes, especially when storing configurations in version control systems.

### OpenID Connect (OIDC) Authentication

OpenID Connect (OIDC) is a secure way to let users log in to your SQLPage application using their existing accounts from popular services. When OIDC is configured, all access to your SQLPage application will require users to log in through the chosen provider. This enables Single Sign-On (SSO), allowing you to restrict access to your application without having to handle authentication yourself.

To set up OIDC, you'll need to:
1. Register your application with an OIDC provider
2. Configure the provider's details in SQLPage

#### Setting Your Application's Address

When users log in through an OIDC provider, they need to be sent back to your application afterward. For this to work correctly, you need to tell SQLPage where your application is located online:

- Use the `host` setting to specify your application's web address (for example, "myapp.example.com")
- If you already have the `https_domain` setting set (to fetch https certificates for your site), then you don't need to duplicate it into `host`.

Example configuration:
```json
{
"oidc_issuer_url": "https://accounts.google.com",
"oidc_client_id": "your-client-id",
"oidc_client_secret": "your-client-secret",
"host": "myapp.example.com"
}
```

#### Cloud Identity Providers

- **Google**
- Documentation: https://developers.google.com/identity/openid-connect/openid-connect
- Set *oidc_issuer_url* to `https://accounts.google.com`

- **Microsoft Entra ID** (formerly Azure AD)
- Documentation: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app
- Set *oidc_issuer_url* to `https://login.microsoftonline.com/{tenant}/v2.0`
- ([Find your tenant name](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri))

- **GitHub**
- Issuer URL: `https://github.com`
- Documentation: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps

#### Self-Hosted Solutions

- **Keycloak**
- Issuer URL: `https://your-keycloak-server/auth/realms/your-realm`
- [Setup Guide](https://www.keycloak.org/getting-started/getting-started-docker)

- **Authentik**
- Issuer URL: `https://your-authentik-server/application/o/your-application`
- [Setup Guide](https://goauthentik.io/docs/providers/oauth2)

After registering your application with the provider, you'll receive a client ID and client secret. These are used to configure SQLPage to work with your chosen provider.

Note: OIDC is optional. If you don't configure it, your SQLPage application will be accessible without authentication.

### Example `.env` file

```bash
Expand Down
11 changes: 11 additions & 0 deletions examples/single sign on/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ services:
- ./sqlpage:/etc/sqlpage
environment:
# OIDC configuration
- SQLPAGE_HOST=localhost:8080
- SQLPAGE_OIDC_ISSUER_URL=http://localhost:8181/realms/sqlpage_demo
- OIDC_AUTHORIZATION_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/auth
- OIDC_TOKEN_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/token
- OIDC_USERINFO_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/userinfo
Expand All @@ -28,6 +30,9 @@ services:
# SQLPage configuration
- RUST_LOG=sqlpage=debug
network_mode: host
depends_on:
keycloak:
condition: service_healthy

keycloak:
build:
Expand All @@ -39,3 +44,9 @@ services:
volumes:
- ./keycloak-configuration.json:/opt/keycloak/data/import/realm.json
network_mode: host
healthcheck:
test: ["CMD-SHELL", "/opt/keycloak/bin/kcadm.sh get realms/sqlpage_demo --server http://localhost:8181 --realm master --user admin --password admin || exit 1"]
interval: 10s
timeout: 2s
retries: 5
start_period: 5s
28 changes: 28 additions & 0 deletions src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::webserver::routing::RoutingConfig;
use anyhow::Context;
use clap::Parser;
use config::Config;
use openidconnect::IssuerUrl;
use percent_encoding::AsciiSet;
use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize};
Expand Down Expand Up @@ -198,6 +199,21 @@ pub struct AppConfig {
#[serde(default = "default_max_file_size")]
pub max_uploaded_file_size: usize,

/// The base URL of the `OpenID` Connect provider.
/// Required when enabling Single Sign-On through an OIDC provider.
pub oidc_issuer_url: Option<IssuerUrl>,
/// The client ID assigned to `SQLPage` when registering with the OIDC provider.
/// Defaults to `sqlpage`.
#[serde(default = "default_oidc_client_id")]
pub oidc_client_id: String,
/// The client secret for authenticating `SQLPage` to the OIDC provider.
/// Required when enabling Single Sign-On through an OIDC provider.
pub oidc_client_secret: Option<String>,
/// Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) to request during OIDC authentication.
/// Defaults to "openid email profile"
#[serde(default = "default_oidc_scopes")]
pub oidc_scopes: 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 All @@ -207,6 +223,10 @@ pub struct AppConfig {
/// using the ACME protocol (requesting a TLS-ALPN-01 challenge).
pub https_domain: Option<String>,

/// The hostname where your application is publicly accessible (e.g., "myapp.example.com").
/// This is used for OIDC redirect URLs. If not set, https_domain will be used instead.
pub host: Option<String>,

/// The email address to use when requesting a certificate from Let's Encrypt.
/// Defaults to `contact@<https_domain>`.
pub https_certificate_email: Option<String>,
Expand Down Expand Up @@ -528,6 +548,14 @@ fn default_markdown_allow_dangerous_protocol() -> bool {
false
}

fn default_oidc_client_id() -> String {
"sqlpage".to_string()
}

fn default_oidc_scopes() -> String {
"openid email profile".to_string()
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum DevOrProd {
Expand Down
46 changes: 2 additions & 44 deletions src/webserver/database/sqlpage_functions/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ use crate::webserver::{
execute_queries::DbConn, sqlpage_functions::url_parameter_deserializer::URLParameters,
},
http::SingleOrVec,
http_client::make_http_client,
request_variables::ParamMap,
ErrorWithStatus,
};
use anyhow::{anyhow, Context};
use futures_util::StreamExt;
use mime_guess::mime;
use std::{borrow::Cow, ffi::OsStr, str::FromStr, sync::OnceLock};
use std::{borrow::Cow, ffi::OsStr, str::FromStr};

super::function_definition_macro::sqlpage_functions! {
basic_auth_password((&RequestInfo));
Expand Down Expand Up @@ -312,49 +313,6 @@ async fn fetch_with_meta(
Ok(return_value)
}

static NATIVE_CERTS: OnceLock<anyhow::Result<rustls::RootCertStore>> = OnceLock::new();

fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result<awc::Client> {
let connector = if config.system_root_ca_certificates {
let roots = NATIVE_CERTS
.get_or_init(|| {
log::debug!("Loading native certificates because system_root_ca_certificates is enabled");
let certs = rustls_native_certs::load_native_certs()
.with_context(|| "Initial native certificates load failed")?;
log::info!("Loaded {} native certificates", certs.len());
let mut roots = rustls::RootCertStore::empty();
for cert in certs {
log::trace!("Adding native certificate to root store: {cert:?}");
roots.add(cert.clone()).with_context(|| {
format!("Unable to add certificate to root store: {cert:?}")
})?;
}
Ok(roots)
})
.as_ref()
.map_err(|e| anyhow!("Unable to load native certificates, make sure the system root CA certificates are available: {e}"))?;

log::trace!("Creating HTTP client with custom TLS connector using native certificates. SSL_CERT_FILE={:?}, SSL_CERT_DIR={:?}",
std::env::var("SSL_CERT_FILE").unwrap_or_default(),
std::env::var("SSL_CERT_DIR").unwrap_or_default());

let tls_conf = rustls::ClientConfig::builder()
.with_root_certificates(roots.clone())
.with_no_client_auth();

awc::Connector::new().rustls_0_22(std::sync::Arc::new(tls_conf))
} else {
log::debug!("Using the default tls connector with builtin certs because system_root_ca_certificates is disabled");
awc::Connector::new()
};
let client = awc::Client::builder()
.connector(connector)
.add_default_header((awc::http::header::USER_AGENT, env!("CARGO_PKG_NAME")))
.finish();
log::debug!("Created HTTP client");
Ok(client)
}

pub(crate) async fn hash_password(password: Option<String>) -> anyhow::Result<Option<String>> {
let Some(password) = password else {
return Ok(None);
Expand Down
2 changes: 2 additions & 0 deletions src/webserver/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use actix_web::{
use actix_web::{HttpResponseBuilder, ResponseError};

use super::https::make_auto_rustls_config;
use super::oidc::OidcMiddleware;
use super::response_writer::ResponseWriter;
use super::static_content;
use crate::webserver::routing::RoutingAction::{
Expand Down Expand Up @@ -466,6 +467,7 @@ pub fn create_app(
)
// when receiving a request outside of the prefix, redirect to the prefix
.default_service(fn_service(default_prefix_redirect))
.wrap(OidcMiddleware::new(&app_state))
.wrap(Logger::default())
.wrap(default_headers(&app_state))
.wrap(middleware::Condition::new(
Expand Down
45 changes: 45 additions & 0 deletions src/webserver/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use anyhow::{anyhow, Context};
use std::sync::OnceLock;

static NATIVE_CERTS: OnceLock<anyhow::Result<rustls::RootCertStore>> = OnceLock::new();

pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result<awc::Client> {
let connector = if config.system_root_ca_certificates {
let roots = NATIVE_CERTS
.get_or_init(|| {
log::debug!("Loading native certificates because system_root_ca_certificates is enabled");
let certs = rustls_native_certs::load_native_certs()
.with_context(|| "Initial native certificates load failed")?;
log::info!("Loaded {} native certificates", certs.len());
let mut roots = rustls::RootCertStore::empty();
for cert in certs {
log::trace!("Adding native certificate to root store: {cert:?}");
roots.add(cert.clone()).with_context(|| {
format!("Unable to add certificate to root store: {cert:?}")
})?;
}
Ok(roots)
})
.as_ref()
.map_err(|e| anyhow!("Unable to load native certificates, make sure the system root CA certificates are available: {e}"))?;

log::trace!("Creating HTTP client with custom TLS connector using native certificates. SSL_CERT_FILE={:?}, SSL_CERT_DIR={:?}",
std::env::var("SSL_CERT_FILE").unwrap_or_default(),
std::env::var("SSL_CERT_DIR").unwrap_or_default());

let tls_conf = rustls::ClientConfig::builder()
.with_root_certificates(roots.clone())
.with_no_client_auth();

awc::Connector::new().rustls_0_22(std::sync::Arc::new(tls_conf))
} else {
log::debug!("Using the default tls connector with builtin certs because system_root_ca_certificates is disabled");
awc::Connector::new()
};
let client = awc::Client::builder()
.connector(connector)
.add_default_header((awc::http::header::USER_AGENT, env!("CARGO_PKG_NAME")))
.finish();
log::debug!("Created HTTP client");
Ok(client)
}
2 changes: 2 additions & 0 deletions src/webserver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod content_security_policy;
pub mod database;
pub mod error_with_status;
pub mod http;
pub mod http_client;
pub mod http_request_info;
mod https;
pub mod request_variables;
Expand All @@ -42,6 +43,7 @@ pub use error_with_status::ErrorWithStatus;

pub use database::make_placeholder;
pub use database::migrations::apply;
mod oidc;
pub mod response_writer;
pub mod routing;
mod static_content;
Loading
Loading