Skip to content
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
4 changes: 3 additions & 1 deletion configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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_protected_paths` | `["/"]` | A list of URL prefixes that should be protected by OIDC authentication. By default, all paths are protected (`["/"]`). If you want to make some pages public, you can restrict authentication to a sub-path, for instance `["/admin", "/users/settings"]`. |
| `oidc_public_paths` | `[]` | A list of URL prefixes that should be publicly available. By default, no paths are publicly accessible (`[]`). If you want to make some pages public, you can bypass authentication for a sub-path, for instance `["/public/", "/assets/"]`. Keep in mind that without the closing backslashes, that any directory or file starting with `public` or `assets` will be publicly available. This will also overwrite any protected path restriction. If you have a private path `/private` and you define the public path `/private/public/` everything in `/private/public/` will be publicly accessible, while everything else in private will still need authentication.
| `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. |
Expand Down Expand Up @@ -90,7 +92,7 @@ This allows you to keep the password separate from the connection string, which

### 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.
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, you can control which parts of your application require authentication using the `oidc_protected_paths` option. By default, all pages are protected. You can specify a list of URL prefixes to protect specific areas, allowing you to have a mix of public and private pages.

To set up OIDC, you'll need to:
1. Register your application with an OIDC provider
Expand Down
41 changes: 36 additions & 5 deletions examples/official-site/sso/single_sign_on.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Setting Up Single Sign-On in SQLPage


When you want to add user authentication to your SQLPage application, you have two main options:

1. The [authentication component](/component.sql?component=authentication):
Expand Down Expand Up @@ -60,9 +59,7 @@ Create or edit `sqlpage/sqlpage.json` to add the following configuration keys:
When you restart your SQLPage instance, it should automatically contact
the identity provider, find its login URL, and the public keys that will be used to check the validity of its identity tokens.

The next time an user loads page on your SQLPage website, they will be redirected to
the provider's login page. Upon successful login, they will be redirected back to
the page they were initially requesting on your website.
By default, all pages on your website will now require users to log in.

## Access User Information in Your SQL

Expand All @@ -89,6 +86,40 @@ values
(sqlpage.path(), sqlpage.user_info('sub'));
```

## Restricting authentication to a specific set of pages

Sometimes, you don't want to protect your entire website with a login, but only a specific section.
You can achieve this by adding the `oidc_protected_paths` option to your `sqlpage.json` file.

This option takes a list of URL prefixes. If a user requests a page whose address starts with one of these prefixes, they will be required to log in.

**Example:** Protect only pages in the `/admin` folder.

```json
{
"oidc_issuer_url": "https://accounts.google.com",
"oidc_client_id": "your-client-id",
"oidc_client_secret": "your-client-secret",
"host": "localhost:8080",
"oidc_protected_paths": ["/admin"]
}
```

In this example, a user visiting `/admin/dashboard.sql` will be prompted to log in, while a user visiting `/index.sql` will not.

### Creating a public login page

A common pattern is to have a public home page with a "Login" button that redirects users to a protected area.

With the configuration above, you can create a public page `login.sql` that is not in a protected path. This page can contain a simple link to a protected resource, for instance `/admin/index.sql`:

```sql
select 'list' as component, 'actions' as title;
select 'Login' as title, '/admin' as link, 'login' as icon;
```

When a non-authenticated user clicks this "Login" link, SQLPage will automatically redirect them to your identity provider's login page. After they successfully authenticate, they will be sent back to the page they originally requested (`/admin/index.sql`).

## Going to Production

When deploying to production:
Expand Down Expand Up @@ -127,4 +158,4 @@ When deploying to production:
- Verify your OIDC provider's logs for authentication attempts
- In production, confirm your domain name matches exactly in both the OIDC provider settings and `sqlpage.json`
- If [using a reverse proxy](/your-first-sql-website/nginx.sql), ensure it's properly configured to handle the OIDC callback path.
- If you have checked everything and you think the bug comes from SQLPage itself, [open an issue on our bug tracker](https://github.com/sqlpage/SQLPage/issues).
- If you have checked everything and you think the bug comes from SQLPage itself, [open an issue on our bug tracker](https://github.com/sqlpage/SQLPage/issues).
26 changes: 10 additions & 16 deletions examples/single sign on/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Depending on your use case, you can choose the appropriate protocol for your app
To run the demo, you just need docker and docker-compose installed on your machine. Then, run the following commands:

```bash
docker-compose up
docker compose up --watch
```

This will start a Keycloak server and a SQLPage server. You can access the SQLPage application at http://localhost:8080.
Expand All @@ -46,14 +46,12 @@ SQLPage has built-in support for OIDC authentication since v0.35.
This project demonstrates how to use it with the free and open source [Keycloak](https://www.keycloak.org/) OIDC provider.
You can easily replace Keycloak with another OIDC provider, such as Google, or your enterprise OIDC provider, by following the steps in the [Configuration](#configuration) section.

### Important Note About OIDC Protection
### Public and Protected Pages

When using SQLPage's built-in OIDC support, the entire website is protected behind authentication. This means:
- All pages require users to be logged in
- There is no way to have public pages alongside protected pages
- Users will be automatically redirected to the OIDC provider's login page when accessing any page
By default, SQLPage's built-in OIDC support protects the entire website. However, you can configure it to have a mix of public and protected pages using the `oidc_protected_paths` option in your `sqlpage.json` file.

This allows you to create a public-facing area (like a homepage with a login button) and a separate protected area for authenticated users.

If you need to have a mix of public and protected pages, you should use the [authentication component](/component.sql?component=authentication) instead.

### Configuration

Expand All @@ -65,7 +63,8 @@ you need to configure it in your `sqlpage.json` file:
"oidc_issuer_url": "https://your-keycloak-server/auth/realms/your-realm",
"oidc_client_id": "your-client-id",
"oidc_client_secret": "your-client-secret",
"host": "localhost:8080"
"host": "localhost:8080",
"oidc_protected_paths": ["/protected"]
}
```

Expand All @@ -91,15 +90,11 @@ select 'text' as component, 'Welcome, ' || sqlpage.user_info('name') || '!' as c

The demo includes several SQL files that demonstrate different aspects of OIDC integration:

1. `index.sql`: Shows how to:
- Display user information using `sqlpage.user_info('email')`
- Show all available user information using `sqlpage.id_token()`
1. `index.sql`: A public page that shows a welcome message and a login button. If the user is logged in, it displays their email and a link to the protected page.

2. `protected.sql`: Demonstrates a page that is accessible to authenticated users
2. `protected.sql`: A page that is only accessible to authenticated users. It displays the user's information.

3. `logout.sql`: Shows how to:
- Remove the authentication cookie
- Redirect to the OIDC provider's logout endpoint
3. `logout.sql`: Logs the user out by removing the authentication cookie and redirecting to the OIDC provider's logout page.

### Docker Setup

Expand All @@ -120,4 +115,3 @@ The demo uses Docker Compose to set up both SQLPage and Keycloak. The configurat
- [SQLPage OIDC Documentation](https://sql-page.com/sso)
- [OpenID Connect](https://openid.net/connect/)
- [Authorization Code Flow](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)

6 changes: 6 additions & 0 deletions examples/single sign on/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
services:
sqlpage:
image: lovasoa/sqlpage:main # Use the latest development version of SQLPage
build:
context: ../..
volumes:
- .:/var/www
- ./sqlpage:/etc/sqlpage
Expand All @@ -23,6 +25,10 @@ services:
depends_on:
keycloak:
condition: service_healthy
develop:
watch:
- action: restart
path: ./sqlpage/

keycloak:
build:
Expand Down
28 changes: 15 additions & 13 deletions examples/single sign on/index.sql
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
set user_email = sqlpage.user_info('email');
SELECT 'shell' as component, 'My public app' as title;

select 'shell' as component, 'My secure app' as title,
'logout' as menu_item;
set email = sqlpage.user_info('email');

select 'text' as component,
'You''re in !' as title,
'You are logged in as *`' || $user_email || '`*.
You have access to the [protected page](protected.sql).
-- For anonymous users
SELECT 'hero' as component,
'/protected' as link,
'Log in' as link_text,
'Welcome' as title,
'You are currently browsing as a guest. Log in to access the protected page.' as description,
'/protected/public/hello.jpeg' as image
WHERE $email IS NULL;

![open door](/assets/welcome.jpeg)'
as contents_md;

select 'list' as component;
select key as title, value as description
from json_each(sqlpage.id_token());
-- For logged-in users
SELECT 'text' as component,
'Welcome back, ' || sqlpage.user_info('name') || '!' as title,
'You are logged in as ' || sqlpage.user_info('email') || '. You can now access the [protected page](/protected) or [log out](/logout).' as contents_md
WHERE $email IS NOT NULL;
8 changes: 0 additions & 8 deletions examples/single sign on/protected.sql

This file was deleted.

17 changes: 17 additions & 0 deletions examples/single sign on/protected/index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
set user_email = sqlpage.user_info('email');

select 'shell' as component, 'My secure app' as title,
'logout' as menu_item;

select 'text' as component,
'You''re in, '|| sqlpage.user_info('name') || ' !' as title,
'You are logged in as *`' || $user_email || '`*.

You have access to this protected page.

![open door](/assets/welcome.jpeg)'
as contents_md;

select 'list' as component;
select key as title, value as description
from json_each(sqlpage.user_info_token());
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions examples/single sign on/sqlpage/sqlpage.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo
oidc_client_id: sqlpage
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
26 changes: 26 additions & 0 deletions src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,28 @@ pub struct AppConfig {
#[serde(default = "default_oidc_scopes")]
pub oidc_scopes: String,

/// Defines a list of path prefixes that should be protected by OIDC authentication.
/// By default, all paths are protected.
/// If you specify a list of prefixes, only requests whose path starts with one of the prefixes will require authentication.
/// For example, if you set this to `["/private"]`, then requests to `/private/some_page.sql` will require authentication,
/// but requests to `/index.sql` will not.
/// NOTE: `OIDC_PUBLIC_PATHS` takes precedence over `OIDC_PROTECTED_PATHS`.
/// For example, if you have `["/private"]` on the `protected_paths` like before, but also `["/private/public"]` on the `public_paths`, then `/private` requires authentication, but `/private/public` requires not authentication.
/// You cannot make a path inside a public path private again. So expanding the previous example, if you now add `/private/public/private_again`, then this path will still be accessible.
#[serde(default = "default_oidc_protected_paths")]
pub oidc_protected_paths: Vec<String>,

/// Defines path prefixes to exclude from OIDC authentication.
/// By default, no paths are excluded.
/// Paths matching these prefixes will not require authentication.
/// For example, if set to `["/public"]`, requests to `/public/some_page.sql` will not require authentication,
/// but requests to `/index.sql` will still require it.
/// To make `/protected/public.sql` public while protecting its containing directory,
/// set `oidc_public_paths` to `["/protected/public.sql"]` and `oidc_protected_paths` to `["/protected"]`.
/// Be aware that any path starting with `/protected/public.sql` (e.g., `/protected/public.sql.backup`) will also become public.
#[serde(default)]
pub oidc_public_paths: 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 Expand Up @@ -546,6 +568,10 @@ fn default_oidc_scopes() -> String {
"openid email profile".to_string()
}

fn default_oidc_protected_paths() -> Vec<String> {
vec!["/".to_string()]
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum DevOrProd {
Expand Down
29 changes: 28 additions & 1 deletion src/webserver/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub struct OidcConfig {
pub issuer_url: IssuerUrl,
pub client_id: String,
pub client_secret: String,
pub protected_paths: Vec<String>,
pub public_paths: Vec<String>,
pub app_host: String,
pub scopes: Vec<Scope>,
}
Expand All @@ -60,13 +62,17 @@ impl TryFrom<&AppConfig> for OidcConfig {
let client_secret = config.oidc_client_secret.as_ref().ok_or(Some(
"The \"oidc_client_secret\" setting is required to authenticate with the OIDC provider",
))?;
let protected_paths: Vec<String> = config.oidc_protected_paths.clone();
let public_paths: Vec<String> = config.oidc_public_paths.clone();

let app_host = get_app_host(config);

Ok(Self {
issuer_url: issuer_url.clone(),
client_id: config.oidc_client_id.clone(),
client_secret: client_secret.clone(),
protected_paths,
public_paths,
scopes: config
.oidc_scopes
.split_whitespace()
Expand All @@ -77,6 +83,14 @@ impl TryFrom<&AppConfig> for OidcConfig {
}
}

impl OidcConfig {
#[must_use]
pub fn is_public_path(&self, path: &str) -> bool {
!self.protected_paths.iter().any(|p| path.starts_with(p))
|| self.public_paths.iter().any(|p| path.starts_with(p))
}
}

fn get_app_host(config: &AppConfig) -> String {
if let Some(host) = &config.host {
return host.clone();
Expand Down Expand Up @@ -178,7 +192,11 @@ pub struct OidcService<S> {
oidc_state: Arc<OidcState>,
}

impl<S> OidcService<S> {
impl<S> OidcService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error>,
S::Future: 'static,
{
pub fn new(service: S, oidc_state: Arc<OidcState>) -> Self {
Self {
service,
Expand All @@ -196,6 +214,14 @@ impl<S> OidcService<S> {
return self.handle_oidc_callback(request);
}

if self.oidc_state.config.is_public_path(request.path()) {
log::debug!(
"The request path {} is not in a public path, skipping OIDC authentication",
request.path()
);
return Box::pin(self.service.call(request));
}

log::debug!("Redirecting to OIDC provider");

let response = build_auth_provider_redirect_response(
Expand Down Expand Up @@ -241,6 +267,7 @@ where

fn call(&self, request: ServiceRequest) -> Self::Future {
log::trace!("Started OIDC middleware request handling");

let oidc_client = Arc::clone(&self.oidc_state.client);
match get_authenticated_user_info(&oidc_client, &request) {
Ok(Some(claims)) => {
Expand Down