diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec9003f..7c8478f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # CHANGELOG.md ## v0.35 + - Add support for [single sign-on using OIDC](sql-page.com/sso) + - Allows protecting access to your website using "Sign in with Google/Microsoft/..." - Fix tooltips not showing on line charts with one or more hidden series - Update default chart colors and text shadows for better readability with all themes - Optimize memory layout by boxing large structs. Slightly reduces memory usage. diff --git a/Cargo.lock b/Cargo.lock index 3976341a..738c143d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -824,6 +830,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1033,6 +1040,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1068,6 +1087,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "darling" version = "0.20.11" @@ -1147,6 +1193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -1273,12 +1320,77 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1365,6 +1477,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "flate2" version = "1.1.1" @@ -1539,6 +1667,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1548,8 +1677,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1595,6 +1726,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1607,7 +1749,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -1630,6 +1772,12 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1965,6 +2113,17 @@ dependencies = [ "quote", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1973,6 +2132,7 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1981,6 +2141,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2371,6 +2540,25 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.16", + "http 1.3.1", + "rand 0.8.5", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -2395,6 +2583,37 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openidconnect" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd50d4a5e7730e754f94d977efe61f611aadd3131f6a2b464f6e3a4167e8ef7" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.3.1", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2407,6 +2626,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2417,6 +2645,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2660,6 +2912,15 @@ dependencies = [ "zerocopy 0.8.24", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2810,6 +3071,16 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3029,6 +3300,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3067,6 +3352,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.219" @@ -3084,13 +3379,23 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "indexmap", + "indexmap 2.9.0", "itoa", "memchr", "ryu", "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -3121,6 +3426,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3236,7 +3571,7 @@ dependencies = [ "async-stream", "async-trait", "awc", - "base64 0.22.1", + "base64 0.21.7", "chrono", "clap", "config", @@ -3251,6 +3586,7 @@ dependencies = [ "log", "markdown", "mime_guess", + "openidconnect", "password-hash", "percent-encoding", "rand 0.9.1", @@ -3318,7 +3654,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "indexmap", + "indexmap 2.9.0", "itoa", "libc", "libsqlite3-sys", @@ -3676,7 +4012,7 @@ version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ - "indexmap", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -3838,6 +4174,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 233970a5..1eac8e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,11 +50,11 @@ include_dir = "0.7.2" config = { version = "0.15.4", features = ["json"] } markdown = { version = "1.0.0-alpha.23", features = ["log"] } password-hash = "0.5.0" -argon2 = "0.5.0" +argon2 = "0.5.3" actix-web-httpauth = "0.8.0" rand = "0.9.0" actix-multipart = "0.7.2" -base64 = "0.22" +base64 = "0.21.7" rustls-acme = "0.9.2" dotenvy = "0.15.7" csv-async = { version = "1.2.6", features = ["tokio"] } @@ -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"] } diff --git a/configuration.md b/configuration.md index 344048d6..798a97d1 100644 --- a/configuration.md +++ b/configuration.md @@ -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
MySql: 75
SQLite: 16
MSSQL: 100 | How many simultaneous database connections to open at most | | `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity | | `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time | @@ -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. | @@ -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 diff --git a/examples/official-site/sqlpage/migrations/07_authentication.sql b/examples/official-site/sqlpage/migrations/07_authentication.sql index 86a25c62..fd342fc6 100644 --- a/examples/official-site/sqlpage/migrations/07_authentication.sql +++ b/examples/official-site/sqlpage/migrations/07_authentication.sql @@ -2,13 +2,41 @@ INSERT INTO component (name, description, icon, introduced_in_version) VALUES ( 'authentication', - 'An advanced component that can be used to create pages with password-restricted access. - When used, this component has to be at the top of your page, because once the page has begun being sent to the browser, it is too late to restrict access to it. - The authentication component checks if the user has sent the correct password, and if not, redirects them to the URL specified in the link parameter. - If you don''t want to re-check the password on every page (which is an expensive operation), - you can check the password only once and store a session token in your database. - You can use the cookie component to set the session token cookie in the client browser, - and then check whether the token matches what you stored in subsequent pages.', + ' +Create pages with password-restricted access. + + +When you want to add user authentication to your SQLPage application, +you have two main options: + +1. The `authentication` component: + - lets you manage usernames and passwords yourself + - does not require any external service + - gives you fine-grained control over + - which pages and actions are protected + - the look of the login form + - the duration of the session + - the permissions of each user +2. [**Single sign-on**](/sso) + - lets users log in with their existing accounts (like Google, Microsoft, or your organization''s own identity provider) + - requires setting up an external service (Google, Microsoft, etc.) + - frees you from implementing a lot of features like password reset, account creation, user management, etc. + +This page describes the first option. + +When used, this component has to be at the top of your page, +because once the page has begun being sent to the browser, +it is too late to restrict access to it. + +The authentication component checks if the user has sent the correct password, +and if not, redirects them to the URL specified in the link parameter. + +If you don''t want to re-check the password on every page (which is an expensive operation), +you can check the password only once and store a session token in your database +(see the session example below). + +You can use the [cookie component](?component=cookie) to set the session token cookie in the client browser, +and then check whether the token matches what you stored in subsequent pages.', 'lock', '0.7.2' ); @@ -158,9 +186,6 @@ RETURNING ### Single sign-on with OIDC (OpenID Connect) If you don''t want to manage your own user database, -you can use OpenID Connect and OAuth2 to authenticate users. -This allows users to log in with their Google, Facebook, or internal company account. - -You will find an example of how to do this in the -[Single sign-on with OIDC](https://github.com/sqlpage/SQLPage/tree/main/examples/single%20sign%20on). +you can [use OpenID Connect and OAuth2](/sso) to authenticate users. +This allows users to log in with their Google, Microsoft, or internal company account. '); diff --git a/examples/official-site/sqlpage/migrations/61_oidc_functions.sql b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql new file mode 100644 index 00000000..eda1c2cc --- /dev/null +++ b/examples/official-site/sqlpage/migrations/61_oidc_functions.sql @@ -0,0 +1,172 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'user_info_token', + '0.35.0', + 'key', + '# Accessing information about the current user, when logged in with SSO + +This function can be used only when you have [configured Single Sign-On with an OIDC provider](/sso). + +## The ID Token + +When a user logs in through OIDC, your application receives an [identity token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) from the identity provider. +This token contains information about the user, such as their name and email address. +The `sqlpage.user_info_token()` function lets you access the entire contents of the ID token, as a JSON object. +You can then use [your database''s JSON functions](/blog.sql?post=JSON+in+SQL%3A+A+Comprehensive+Guide) to process that JSON. + +If you need to access a specific claim, it is easier and more performant to use the +[`sqlpage.user_info()`](?function=user_info) function instead. + +### Example: Displaying User Information + +```sql +select ''list'' as component; +select key as title, value as description +from json_each(sqlpage.user_info_token()); +``` + +This sqlite-specific example will show all the information available about the current user, such as: +- `sub`: A unique identifier for the user +- `name`: The user''s full name +- `email`: The user''s email address +- `picture`: A URL to the user''s profile picture + +### Security Notes + +- The ID token is automatically verified by SQLPage to ensure it hasn''t been tampered with. +- The token is only available to authenticated users: if no user is logged in or sso is not configured, this function returns NULL +- If some information is not available in the token, you have to configure it on your OIDC provider, SQLPage can''t do anything about it. +- The token is stored in a signed http-only cookie named `sqlpage_auth`. You can use [the cookie component](/component.sql?component=cookie) to delete it, and the user will be redirected to the login page on the next page load. +' + ); + +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'user_info', + '0.34.0', + 'user', + '# Accessing Specific User Information + +The `sqlpage.user_info` function is a convenient way to access specific pieces of information about the currently logged-in user. +When you [configure Single Sign-On](/sso), your OIDC provider will issue an [ID token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) for the user, +which contains *claims*, with information about the user. + +Calling `sqlpage.user_info(claim_name)` lets you access these claims directly from SQL. + +## How to Use + +The function takes one parameter: the name of the *claim* (the piece of information you want to retrieve). + +For example, to display a personalized welcome message, with the user''s name, you can use: + +```sql +select ''text'' as component; +select ''Welcome, '' || sqlpage.user_info(''name'') || ''!'' as title; +``` + +## Available Information + +The exact information available depends on your identity provider (the service you chose to authenticate with), +its configuration, and the scopes you requested. +Use [`sqlpage.user_info_token()`](?function=user_info_token) to see all the information available in the ID token of the current user. + +Here are some commonly available fields: + +### Basic Information +- `name`: The user''s full name (usually first and last name separated by a space) +- `email`: The user''s email address (*warning*: there is no guarantee that the user currently controls this email address. Use the `sub` claim for database references instead.) +- `picture`: URL to the user''s profile picture + +### User Identifiers +- `sub`: A unique identifier for the user (use this to uniquely identify the user in your database) +- `preferred_username`: The username the user prefers to use + +### Name Components +- `given_name`: The user''s first name +- `family_name`: The user''s last name + +## Examples + +### Personalized Welcome Message +```sql +select ''text'' as component, + ''Welcome back, **'' || sqlpage.user_info(''given_name'') || ''**!'' as contents_md; +``` + +### User Profile Card +```sql +select ''card'' as component; +select + sqlpage.user_info(''name'') as title, + sqlpage.user_info(''email'') as description, + sqlpage.user_info(''picture'') as image; +``` + +### Conditional Content Based on custom claims + +Some identity providers let you add custom claims to the ID token. +This lets you customize the behavior of your application based on arbitrary user attributes, +such as the user''s role. + +```sql +-- show everything to admins, only public items to others +select ''list'' as component; +select title from my_items + where is_public or sqlpage.user_info(''role'') = ''admin'' +``` + +## Security Best Practices + +> ⚠️ **Important**: Always use the `sub` claim to identify users in your database, not their email address. +> The `sub` claim is guaranteed to be unique and stable for each user, while email addresses can change. +> In most providers, receiving an id token with a given email does not guarantee that the user currently controls that email. + +```sql +-- Store the user''s ID in your database +insert into user_preferences (user_id, theme) +values (sqlpage.user_info(''sub''), ''dark''); +``` + +## Troubleshooting + +If you''re not getting the information you expect: + +1. Check that OIDC is properly configured in your `sqlpage.json` +2. Verify that you requested the right scopes in your OIDC configuration +3. Try using `sqlpage.user_info_token()` to see all available information +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. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'user_info', + 1, + 'claim', + 'The name of the user information to retrieve. Common values include ''name'', ''email'', ''picture'', ''sub'', ''preferred_username'', ''given_name'', and ''family_name''. The exact values available depend on your OIDC provider and configuration.', + 'TEXT' + ); \ No newline at end of file diff --git a/examples/official-site/sso/index.sql b/examples/official-site/sso/index.sql new file mode 100644 index 00000000..c352d9e6 --- /dev/null +++ b/examples/official-site/sso/index.sql @@ -0,0 +1,7 @@ +select 'http_header' as component, + 'public, max-age=600, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control", + '; rel="canonical"' as "Link"; + +select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1; + +select 'text' as component, sqlpage.read_file_as_text('sso/single_sign_on.md') as contents_md, true as article; \ No newline at end of file diff --git a/examples/official-site/sso/single_sign_on.md b/examples/official-site/sso/single_sign_on.md new file mode 100644 index 00000000..5f2eb2ec --- /dev/null +++ b/examples/official-site/sso/single_sign_on.md @@ -0,0 +1,131 @@ +# 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): + A simple username/password system, that you have to manage yourself. +2. **OpenID Connect (OIDC)**: + A single sign-on system that lets users log in with their existing accounts (like Google, Microsoft, or your organization's own identity provider). + +This guide will help you set up single sign-on using OpenID connect with SQLPage quickly. + +## Essential Terms + +- **OIDC** ([OpenID Connect](https://openid.net/developers/how-connect-works/)): The protocol that enables secure login with existing accounts. While it adds some complexity, it's an industry standard that ensures your users' data stays safe. +- **Issuer** (or identity provider): The service that verifies your users' identity (like Google or Microsoft) +- **Identity Token**: A secure message from the issuer containing user information. It is stored as a cookie on the user's computer, and sent with every request after login. SQLPage will redirect all requests that do not contain a valid token to the identity provider's login page. +- **Claim**: A piece of information contained in the token about the user (like their name or email) + +## Quick Setup Guide + +### Choose an OIDC Provider + +Here are the setup guides for +[Google](https://developers.google.com/identity/openid-connect/openid-connect), +[Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app), +[GitHub](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps), +and [Keycloak](https://www.keycloak.org/getting-started/getting-started-docker) (self-hosted). + +### Register Your Application + +1. Go to your chosen provider's developer console +2. Create a new application +3. Set the redirect URI to `http://localhost:8080/sqlpage/oidc_callback`. (We will change that later when you deploy your site to a hosting provider such as [datapage](https://beta.datapage.app/)). +4. Note down the client ID and client secret + +### Configure SQLPage + +Create or edit `sqlpage/sqlpage.json` to add the following configuration keys: + +```json +{ + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080" +} +``` + +#### Provider-specific settings +- Google: `https://accounts.google.com` +- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0`. [Find your value of `{tenant}`](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-create-new-tenant). +- GitHub: `https://github.com` +- Keycloak: Use [your realm's base url](https://www.keycloak.org/securing-apps/oidc-layers), ending in `/auth/realms/{realm}`. +- For other OIDC providers, you can usually find the issuer URL by + looking for a "discovery document" or "well-known configuration" at an URL that ends with the suffix `/.well-known/openid-configuration`. + Strip the suffix and use it as the `oidc_issuer_url`. + +### Restart SQLPage + +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. + +## Access User Information in Your SQL + +Once you have successfully configured SSO, you can access information +about the authenticated user who is visiting the current page using the following functions: +- [`sqlpage.user_info`](/functions.sql?function=user_info) to access a particular claim about the user such as `name` or `email`, +- [`sqlpage.user_info_token`](/functions.sql?function=user_info_token) to access the entire identity token as json. + +Access user data in your SQL files: + +```sql +select 'text' as component, ' + +Welcome, ' || sqlpage.user_info('name') || '! + +You have visited this site ' || + (select count(*) from page_visits where user=sqlpage.user_info('sub')) || +' times before. +' as contents_md; + +insert into page_visits + (path, user) +values + (sqlpage.path(), sqlpage.user_info('sub')); +``` + +## Going to Production + +When deploying to production: + +1. Update the redirect URI in your OIDC provider's settings to: + ``` + https://your-domain.com/sqlpage/oidc_callback + ``` + +2. Update your `sqlpage.json`: + ```json + { + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "your-domain.com" + } + ``` + +3. If you're using HTTPS (recommended), make sure your `host` setting matches your domain name exactly. + +## Troubleshooting + +### Version Requirements +- OIDC support requires SQLPage **version 0.35 or higher**. Check your version in the logs. + +### Common Configuration Issues +- **Redirect URI Mismatch**: The redirect URI in your OIDC provider settings must exactly match `https://your-domain.com/sqlpage/oidc_callback` (or `http://localhost:8080/sqlpage/oidc_callback` for local development) +- **Invalid Client Credentials**: Double-check your client ID and secret are copied correctly from your OIDC provider +- **Host Configuration**: The `host` setting in `sqlpage.json` must match your application's domain name exactly +- **HTTPS Requirements**: Most OIDC providers require HTTPS in production. Ensure your site is served over HTTPS. +- **Provider Discovery**: If SQLPage fails to discover your provider's configuration, verify the `oidc_issuer_url` is correct and accessible by loading `{oidc_issuer_url}/.well-known/openid-configuration` in your browser. + +### Debugging Tips +- Check SQLPage's logs for detailed error messages. You can enable verbose logging with the `RUST_LOG=trace` environment variable. +- 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). diff --git a/examples/single sign on/README.md b/examples/single sign on/README.md index ad89a5aa..be262e5c 100644 --- a/examples/single sign on/README.md +++ b/examples/single sign on/README.md @@ -1,7 +1,7 @@ # SQLPage Single Sign-On demo This project demonstrates how to implement -external authentication (Single Sign-On) in a SQLPage application. +external authentication (Single Sign-On) in a SQLPage application using SQLPage's built-in OIDC support. It demonstrates the implementation of two external authentication protocols: - [OpenID Connect (OIDC)](https://openid.net/connect/) @@ -42,66 +42,82 @@ the [CAS protocol](https://apereo.github.io/cas/) (version 3.0), which is mostly OIDC is an authentication protocol that allows users to authenticate with a third-party identity provider and then access applications without having to log in again. This is useful for single sign-on (SSO) scenarios where users need to access multiple applications with a single set of credentials. OIDC can be used to implement a "Login with Google" or "Login with Facebook" button in your application, since these providers support the OIDC protocol. -SQLPage currently doesn't have a native OIDC implementation, but you can implement OIDC authentication in your SQLPage app yourself. +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. -This project provides a basic implementation of OIDC authentication in a SQLPage application. It uses the free and open source [Keycloak](https://www.keycloak.org/) OIDC provider -to authenticate users. 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 -### Configuration +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 -If you want to use this implementation in your own SQLPage application, -with a different OIDC provider, here are the steps you need to follow: +If you need to have a mix of public and protected pages, you should use the [authentication component](/component.sql?component=authentication) instead. -1. Create an OIDC application in your OIDC provider (e.g., Keycloak). You will need to provide the following information: - - **Client type** (`public` or `confidential`). For this implementation, you should use `confidential` (sometimes called `regular web application:`, `server-side`, `backend`, or `Authorization Code Flow`). In Keycloak, this is set by switching on the `Client Authentication` toggle. - - **Client ID**: This is a unique identifier for your application. Choose a short and descriptive name for your application without spaces or special characters. - - **Redirect URI**: This is the URL of your SQLPage application, followed by `/oidc_redirect_handler.sql`. For example, `https://example.com/oidc_redirect_handler.sql`. - - **Logout redirect URI**: This is the URL where the user should be redirected after logging out. For this implementation, we use the home page URL: `https://example.com/`. +### Configuration -2. Once the application is created, the provider will give you the following information: - - **Client secret**: This is a secret key that is used to authenticate your application with the OIDC provider. You will need to provide this value to your SQLPage application as an environment variable. +To use OIDC authentication in your own SQLPage application, +you need to configure it in your `sqlpage.json` file: +```json +{ + "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" +} +``` -3. Once you have the client ID and client secret, you can configure your SQLPage application to use OIDC authentication. You will need to set the following [environment variables](https://en.wikipedia.org/wiki/Environment_variable) in your SQLPage application: +The configuration parameters are: +- `oidc_issuer_url`: The base URL of your OIDC provider +- `oidc_client_id`: The ID that identifies your SQLPage application to the OIDC provider +- `oidc_client_secret`: The secret key for your SQLPage application +- `host`: The web address where your application is accessible -- `OIDC_CLIENT_ID`: The value you chose for the client ID of your OIDC application. -- `OIDC_CLIENT_SECRET`: The client secret of your OIDC application that you received from the OIDC provider in step 2. -- `OIDC_AUTHORIZATION_ENDPOINT`: The authorization endpoint of your OIDC provider. This is the URL where the user is redirected to log in. For Keycloak, this is usually `your-keycloak-url/auth/realms/master/protocol/openid-connect/auth`. For Google, this is `https://accounts.google.com/o/oauth2/auth`. -- `OIDC_TOKEN_ENDPOINT`: The token endpoint of your OIDC provider. This is the URL where the application exchanges the authorization code for an access token. For Keycloak, this is usually `your-keycloak-url/auth/realms/master/protocol/openid-connect/token`. For Google, this is `https://oauth2.googleapis.com/token`. -- `OIDC_USERINFO_ENDPOINT`: The userinfo endpoint of your OIDC provider. This is the URL where the application can retrieve information about the authenticated user. For Keycloak, this is usually `your-keycloak-url/auth/realms/master/protocol/openid-connect/userinfo`. For Google, this is `https://openidconnect.googleapis.com/v1/userinfo`. -- `OIDC_END_SESSION_ENDPOINT`: The logout endpoint of your OIDC provider. This is the URL where the application can redirect the user to log out. For Keycloak, this is usually `your-keycloak-url/auth/realms/master/protocol/openid-connect/logout`. +### Accessing User Information -In order to find the various endpoints for your OIDC provider, you can refer to the OIDC provider's **Discovery Document**, at the URL `base-url/.well-known/openid-configuration`. +Once OIDC is configured, you can access information about the authenticated user in your SQL files using these functions: -Here is a screenshot of the Keycloak configuration for the demo application: +- `sqlpage.user_info(claim_name)`: Get a specific claim about the user (like name or email) +- `sqlpage.user_info_token()`: Get the entire identity token as JSON -![Keycloak Configuration](assets/keycloak_configuration.png) +Example: +```sql +select 'text' as component, 'Welcome, ' || sqlpage.user_info('name') || '!' as contents_md; +``` -## Code Overview +### Implementation Details -### `login.sql` +The demo includes several SQL files that demonstrate different aspects of OIDC integration: -The [`login.sql`](./login.sql) file simply redirects the user to the OIDC provider's authorization endpoint. -The provider is then responsible for authenticating the user and redirecting them back to the SQLPage application's `oidc_redirect_handler.sql` script. +1. `index.sql`: Shows how to: + - Display user information using `sqlpage.user_info('email')` + - Show all available user information using `sqlpage.id_token()` -### `oidc_redirect_handler.sql` -The main logic is contained in the [`oidc_redirect_handler.sql`](./oidc_redirect_handler.sql) -file. This script handles the OIDC redirect after the user has authenticated with the OIDC provider. It performs the following steps: +2. `protected.sql`: Demonstrates a page that is accessible to authenticated users -1. Checks if the `oauth_state` cookie matches the `state` parameter in the query string. This is a security measure to prevent CSRF attacks. If the states do not match, the user is redirected to the login page. +3. `logout.sql`: Shows how to: + - Remove the authentication cookie + - Redirect to the OIDC provider's logout endpoint -2. Exchanges the authorization code for an access token. This is done by making a POST request to the OIDC provider's token endpoint. The request includes the authorization code, the redirect URI, and the client ID and secret. +### Docker Setup -3. If the access token cannot be obtained, the user is redirected to the login page. +The demo uses Docker Compose to set up both SQLPage and Keycloak. The configuration includes: -### `logout.sql` +- SQLPage service with: + - Volume mounts for the web root and configuration + - CAS configuration for optional CAS support + - Debug logging enabled -The [`logout.sql`](./logout.sql) file simply clears the `session_id` cookie, -removes the session information from the database, and redirects the user to the OIDC provider's logout endpoint. +- Keycloak service with: + - Pre-configured realm and users + - Health checks to ensure it's ready before SQLPage starts + - Admin credentials for management ## References -- An accessible explanation of OIDC: https://annotate.dev/p/hello-world/learn-oauth-2-0-by-building-your-own-oauth-client-U2HaZNtvQojn4F +- [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) diff --git a/examples/single sign on/docker-compose.yaml b/examples/single sign on/docker-compose.yaml index 9aa93a8c..5c372ff8 100644 --- a/examples/single sign on/docker-compose.yaml +++ b/examples/single sign on/docker-compose.yaml @@ -13,14 +13,6 @@ services: - .:/var/www - ./sqlpage:/etc/sqlpage environment: - # OIDC configuration - - 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 - - OIDC_END_SESSION_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/logout - - OIDC_CLIENT_ID=sqlpage - - OIDC_CLIENT_SECRET=qiawfnYrYzsmoaOZT28rRjPPRamfvrYr - # CAS (central authentication system) configuration # (you can ignore this if you're only using OpenID Connect) - CAS_ROOT_URL=http://localhost:8181/realms/sqlpage_demo/protocol/cas @@ -28,6 +20,9 @@ services: # SQLPage configuration - RUST_LOG=sqlpage=debug network_mode: host + depends_on: + keycloak: + condition: service_healthy keycloak: build: @@ -39,3 +34,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 diff --git a/examples/single sign on/index.sql b/examples/single sign on/index.sql index 91273413..09e3785d 100644 --- a/examples/single sign on/index.sql +++ b/examples/single sign on/index.sql @@ -1,15 +1,16 @@ -set user_email = (select email from user_sessions where session_id = sqlpage.cookie('session_id')); +set user_email = sqlpage.user_info('email'); select 'shell' as component, 'My secure app' as title, - (case when $user_email is null then 'login' else 'logout' end) as menu_item; - -select 'text' as component, sqlpage.read_file_as_text('assets/homepage.md') as contents_md where $user_email is null; + 'logout' as menu_item; select 'text' as component, 'You''re in !' as title, - 'You are now logged in as *`' || $user_email || '`*. + 'You are logged in as *`' || $user_email || '`*. You have access to the [protected page](protected.sql). ![open door](/assets/welcome.jpeg)' - as contents_md -where $user_email is not null; \ No newline at end of file + as contents_md; + +select 'list' as component; +select key as title, value as description +from json_each(sqlpage.id_token()); \ No newline at end of file diff --git a/examples/single sign on/keycloak-configuration.json b/examples/single sign on/keycloak-configuration.json index 63188a69..34b56fcd 100644 --- a/examples/single sign on/keycloak-configuration.json +++ b/examples/single sign on/keycloak-configuration.json @@ -3597,7 +3597,7 @@ "alwaysDisplayInConsole": true, "clientAuthenticatorType": "client-secret", "secret": "qiawfnYrYzsmoaOZT28rRjPPRamfvrYr", - "redirectUris": ["http://localhost:8080/oidc_redirect_handler.sql"], + "redirectUris": ["http://localhost:8080/sqlpage/oidc_callback"], "webOrigins": ["http://localhost:8080"], "notBefore": 0, "bearerOnly": false, diff --git a/examples/single sign on/login.sql b/examples/single sign on/login.sql deleted file mode 100644 index f2f298b1..00000000 --- a/examples/single sign on/login.sql +++ /dev/null @@ -1,13 +0,0 @@ -set oauth_state = sqlpage.random_string(32); - -SELECT 'cookie' as component, 'oauth_state' as name, $oauth_state as value; - -select 'redirect' as component, - sqlpage.environment_variable('OIDC_AUTHORIZATION_ENDPOINT') - || '?response_type=code' - || '&client_id=' || sqlpage.url_encode(sqlpage.environment_variable('OIDC_CLIENT_ID')) - || '&redirect_uri=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/oidc_redirect_handler.sql' - || '&state=' || $oauth_state - || '&scope=openid+profile+email' - || '&nonce=' || sqlpage.random_string(32) - as link; \ No newline at end of file diff --git a/examples/single sign on/logout.sql b/examples/single sign on/logout.sql index 1abbf15b..5195a439 100644 --- a/examples/single sign on/logout.sql +++ b/examples/single sign on/logout.sql @@ -1,10 +1,9 @@ -- remove the session cookie -select 'cookie' as component, 'session_id' as name, true as remove; --- remove the session from the database -delete from user_sessions where session_id = sqlpage.cookie('session_id') -returning 'redirect' as component, -- redirect the user to the oidc provider to logout - sqlpage.environment_variable('OIDC_END_SESSION_ENDPOINT') - || '?post_logout_redirect_uri=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/' - || '&client_id=' || sqlpage.environment_variable('OIDC_CLIENT_ID') - || '&id_token_hint=' || oidc_token - as link; \ No newline at end of file +select + 'cookie' as component, + 'sqlpage_auth' as name, + true as remove; + +select + 'redirect' as component, + 'http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/logout' as link; \ No newline at end of file diff --git a/examples/single sign on/oidc_redirect_handler.sql b/examples/single sign on/oidc_redirect_handler.sql deleted file mode 100644 index d0f036e3..00000000 --- a/examples/single sign on/oidc_redirect_handler.sql +++ /dev/null @@ -1,47 +0,0 @@ --- If the oauth_state cookie does not match the state parameter in the query string, then the request is invalid (CSRF attack) --- and we should redirect the user to the login page. -select 'redirect' as component, '/login.sql' as link where sqlpage.cookie('oauth_state') != $state; - --- Exchange the authorization code for an access token -set authorization_code_request = json_object( - 'url', sqlpage.environment_variable('OIDC_TOKEN_ENDPOINT'), - 'method', 'POST', - 'headers', json_object( - 'Content-Type', 'application/x-www-form-urlencoded' - ), - 'body', 'grant_type=authorization_code' - || '&code=' || $code - || '&redirect_uri=' || sqlpage.protocol() || '://' || sqlpage.header('host') || '/oidc_redirect_handler.sql' - || '&client_id=' || sqlpage.environment_variable('OIDC_CLIENT_ID') - || '&client_secret=' || sqlpage.environment_variable('OIDC_CLIENT_SECRET') -); -set access_token = sqlpage.fetch($authorization_code_request); - --- Redirect the user to the login page if the access token could not be obtained -select 'redirect' as component, '/login.sql' as link where $access_token->>'error' is not null; - --- At this point we have $access_token which contains {"access_token":"eyJ...", "scope":"openid profile email" } - --- Fetch the user's profile -set profile_request = json_object( - 'url', sqlpage.environment_variable('OIDC_USERINFO_ENDPOINT'), - 'method', 'GET', - 'headers', json_object( - 'Authorization', 'Bearer ' || ($access_token->>'access_token') - ) -); -set user_profile = sqlpage.fetch($profile_request); - --- Redirect the user to the login page if the user's profile could not be obtained -select 'redirect' as component, '/login.sql' as link where $user_profile->>'error' is not null; - --- at this point we have $user_profile which contains {"sub":"0cc01234","email_verified":false,"name":"John Smith","preferred_username":"demo","given_name":"John","family_name":"Smith","email":"demo@example.com"} - --- Now we have a valid access token, we can create a session for the user --- in our database -insert into user_sessions(session_id, user_id, email, oidc_token) - values(sqlpage.random_string(32), $user_profile->>'sub', $user_profile->>'email', $access_token->>'id_token') -- you can get additional information like 'name', 'given_name', 'family_name', 'email_verified', 'preferred_username', 'picture' from the user profile - returning 'cookie' as component, 'session_id' as name, session_id as value; - --- Redirect the user to the home page -select 'redirect' as component, '/' as link; \ No newline at end of file diff --git a/examples/single sign on/protected.sql b/examples/single sign on/protected.sql index 1683ab77..1b8bd3bd 100644 --- a/examples/single sign on/protected.sql +++ b/examples/single sign on/protected.sql @@ -1,8 +1,5 @@ -select 'redirect' as component, '/login.sql' as link -where not exists(select * from user_sessions where session_id = sqlpage.cookie('session_id')); - - select 'card' as component, 'My secure protected page' as title, 1 as columns; + select 'Secret video' as title, 'https://www.youtube.com/embed/mXdgmSdaXkg' as embed, diff --git a/examples/single sign on/sqlpage/sqlpage.yaml b/examples/single sign on/sqlpage/sqlpage.yaml new file mode 100644 index 00000000..b2e42599 --- /dev/null +++ b/examples/single sign on/sqlpage/sqlpage.yaml @@ -0,0 +1,3 @@ +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 diff --git a/src/app_config.rs b/src/app_config.rs index 12bc1463..462591aa 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -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}; @@ -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, + /// 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, + /// 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. @@ -207,6 +223,10 @@ pub struct AppConfig { /// using the ACME protocol (requesting a TLS-ALPN-01 challenge). pub https_domain: Option, + /// 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, + /// The email address to use when requesting a certificate from Let's Encrypt. /// Defaults to `contact@`. pub https_certificate_email: Option, @@ -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 { diff --git a/src/lib.rs b/src/lib.rs index f3679f4b..3d0efe57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,8 +83,10 @@ pub mod webserver; use crate::app_config::AppConfig; use crate::filesystem::FileSystem; use crate::webserver::database::ParsedSqlFile; +use crate::webserver::oidc::OidcState; use file_cache::FileCache; use std::path::{Path, PathBuf}; +use std::sync::Arc; use templates::AllTemplates; use webserver::Database; @@ -102,6 +104,7 @@ pub struct AppState { sql_file_cache: FileCache, file_system: FileSystem, config: AppConfig, + pub oidc_state: Option>, } impl AppState { @@ -117,12 +120,16 @@ impl AppState { PathBuf::from("index.sql"), ParsedSqlFile::new(&db, include_str!("../index.sql"), Path::new("index.sql")), ); + + let oidc_state = crate::webserver::oidc::initialize_oidc_state(config).await?; + Ok(AppState { db, all_templates, sql_file_cache, file_system, config: config.clone(), + oidc_state, }) } } diff --git a/src/template_helpers.rs b/src/template_helpers.rs index 2896afb4..76bb8f77 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -628,15 +628,15 @@ mod tests { const ESCAPED_UNSAFE_MARKUP: &str = "<table><tr><td>"; #[test] fn test_html_blocks_with_various_settings() { - let helper = MarkdownHelper::default(); - let content = contents(); - struct TestCase { name: &'static str, preset: Option, expected_output: Result<&'static str, String>, } + let helper = MarkdownHelper::default(); + let content = contents(); + let test_cases = [ TestCase { name: "default settings", diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 7142fee7..62d14440 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -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)); @@ -30,6 +31,7 @@ super::function_definition_macro::sqlpage_functions! { header((&RequestInfo), name: Cow); headers((&RequestInfo)); + user_info_token((&RequestInfo)); link(file: Cow, parameters: Option>, hash: Option>); path((&RequestInfo)); @@ -46,6 +48,7 @@ super::function_definition_macro::sqlpage_functions! { uploaded_file_path((&RequestInfo), upload_name: Cow); uploaded_file_name((&RequestInfo), upload_name: Cow); url_encode(raw_text: Option>); + user_info((&RequestInfo), claim: Cow); variables((&RequestInfo), get_or_post: Option>); version(); @@ -312,49 +315,6 @@ async fn fetch_with_meta( Ok(return_value) } -static NATIVE_CERTS: OnceLock> = OnceLock::new(); - -fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result { - 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) -> anyhow::Result> { let Some(password) = password else { return Ok(None); @@ -760,3 +720,93 @@ async fn headers(request: &RequestInfo) -> String { async fn client_ip(request: &RequestInfo) -> Option { Some(request.client_ip?.to_string()) } + +/// Returns the ID token claims as a JSON object. +async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + Ok(Some(serde_json::to_string(claims)?)) +} + +/// Returns a specific claim from the ID token. +async fn user_info<'a>( + request: &'a RequestInfo, + claim: Cow<'a, str>, +) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + + // Match against known OIDC claims accessible via direct methods. + let claim_value_str = match claim.as_ref() { + // Core Claims + "iss" => Some(claims.issuer().to_string()), + // aud requires serialization: handled separately if needed + "exp" => Some(claims.expiration().timestamp().to_string()), + "iat" => Some(claims.issue_time().timestamp().to_string()), + "sub" => Some(claims.subject().to_string()), + "auth_time" => claims.auth_time().map(|t| t.timestamp().to_string()), + "nonce" => claims.nonce().map(|n| n.secret().to_string()), // Assuming Nonce has secret() + "acr" => claims.auth_context_ref().map(|acr| acr.to_string()), + // amr requires serialization: handled separately if needed + "azp" => claims.authorized_party().map(|azp| azp.to_string()), + "at_hash" => claims.access_token_hash().map(|h| h.to_string()), + "c_hash" => claims.code_hash().map(|h| h.to_string()), + + // Standard Claims (Profile Scope - subset) + "name" => claims + .name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "given_name" => claims + .given_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "family_name" => claims + .family_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "middle_name" => claims + .middle_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "nickname" => claims + .nickname() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "preferred_username" => claims.preferred_username().map(|u| u.to_string()), + "profile" => claims + .profile() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "picture" => claims + .picture() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "website" => claims + .website() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "gender" => claims.gender().map(|g| g.to_string()), // Assumes GenderClaim impls ToString + "birthdate" => claims.birthdate().map(|b| b.to_string()), // Assumes Birthdate impls ToString + "zoneinfo" => claims.zoneinfo().map(|z| z.to_string()), // Assumes ZoneInfo impls ToString + "locale" => claims.locale().map(std::string::ToString::to_string), // Assumes Locale impls ToString + "updated_at" => claims.updated_at().map(|t| t.timestamp().to_string()), + + // Standard Claims (Email Scope) + "email" => claims.email().map(|e| e.to_string()), + "email_verified" => claims.email_verified().map(|b| b.to_string()), + + // Standard Claims (Phone Scope) + "phone_number" => claims.phone_number().map(|p| p.to_string()), + "phone_number_verified" => claims.phone_number_verified().map(|b| b.to_string()), + additional_claim => claims + .additional_claims() + .0 + .get(additional_claim) + .map(std::string::ToString::to_string), + }; + + Ok(claim_value_str) +} diff --git a/src/webserver/http.rs b/src/webserver/http.rs index fc1ecea9..19e5c7a5 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -19,7 +19,9 @@ use actix_web::{ }; use actix_web::{HttpResponseBuilder, ResponseError}; +use super::http_client::make_http_client; 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::{ @@ -466,6 +468,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( @@ -476,6 +479,7 @@ pub fn create_app( middleware::TrailingSlash::MergeOnly, )) .app_data(payload_config(&app_state)) + .app_data(make_http_client(&app_state.config)) .app_data(form_config(&app_state)) .app_data(app_state) } diff --git a/src/webserver/http_client.rs b/src/webserver/http_client.rs new file mode 100644 index 00000000..c8a43701 --- /dev/null +++ b/src/webserver/http_client.rs @@ -0,0 +1,58 @@ +use actix_web::dev::ServiceRequest; +use anyhow::{anyhow, Context}; +use std::sync::OnceLock; + +static NATIVE_CERTS: OnceLock> = OnceLock::new(); + +pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result { + 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::debug!("Loaded {} native HTTPS client 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) fn get_http_client_from_appdata( + request: &ServiceRequest, +) -> anyhow::Result<&awc::Client> { + if let Some(result) = request.app_data::>() { + result + .as_ref() + .map_err(|e| anyhow!("HTTP client initialization failed: {e}")) + } else { + Err(anyhow!("HTTP client not found in app data")) + } +} diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs index 1ecfd86b..23675a51 100644 --- a/src/webserver/http_request_info.rs +++ b/src/webserver/http_request_info.rs @@ -10,6 +10,7 @@ use actix_web::http::header::CONTENT_TYPE; use actix_web::web; use actix_web::web::Form; use actix_web::FromRequest; +use actix_web::HttpMessage as _; use actix_web::HttpRequest; use actix_web_httpauth::headers::authorization::Authorization; use actix_web_httpauth::headers::authorization::Basic; @@ -21,6 +22,7 @@ use std::rc::Rc; use std::sync::Arc; use tokio_stream::StreamExt; +use super::oidc::OidcClaims; use super::request_variables::param_map; use super::request_variables::ParamMap; @@ -39,6 +41,7 @@ pub struct RequestInfo { pub app_state: Arc, pub clone_depth: u8, pub raw_body: Option>, + pub oidc_claims: Option, } impl RequestInfo { @@ -58,6 +61,7 @@ impl RequestInfo { app_state: self.app_state.clone(), clone_depth: self.clone_depth + 1, raw_body: self.raw_body.clone(), + oidc_claims: self.oidc_claims.clone(), } } } @@ -102,6 +106,8 @@ pub(crate) async fn extract_request_info( .ok() .map(Authorization::into_scheme); + let oidc_claims: Option = req.extensions().get::().cloned(); + Ok(RequestInfo { method, path: req.path().to_string(), @@ -116,6 +122,7 @@ pub(crate) async fn extract_request_info( protocol, clone_depth: 0, raw_body, + oidc_claims, }) } diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 1393d9e6..4de28ead 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -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; @@ -42,6 +43,7 @@ pub use error_with_status::ErrorWithStatus; pub use database::make_placeholder; pub use database::migrations::apply; +pub mod oidc; pub mod response_writer; pub mod routing; mod static_content; diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs new file mode 100644 index 00000000..ca8131ed --- /dev/null +++ b/src/webserver/oidc.rs @@ -0,0 +1,627 @@ +use std::future::ready; +use std::{future::Future, pin::Pin, str::FromStr, sync::Arc}; + +use crate::webserver::http_client::get_http_client_from_appdata; +use crate::{app_config::AppConfig, AppState}; +use actix_web::{ + body::BoxBody, + cookie::Cookie, + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + middleware::Condition, + web::{self, Query}, + Error, HttpMessage, HttpResponse, +}; +use anyhow::{anyhow, Context}; +use awc::Client; +use openidconnect::{ + core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, CsrfToken, EndpointMaybeSet, + EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, + TokenResponse, +}; +use serde::{Deserialize, Serialize}; + +use super::http_client::make_http_client; + +type LocalBoxFuture = Pin + 'static>>; + +const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth"; +const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback"; +const SQLPAGE_STATE_COOKIE_NAME: &str = "sqlpage_oidc_state"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OidcAdditionalClaims(pub(crate) serde_json::Map); + +impl openidconnect::AdditionalClaims for OidcAdditionalClaims {} +type OidcToken = openidconnect::IdToken< + OidcAdditionalClaims, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJwsSigningAlgorithm, +>; +pub type OidcClaims = + openidconnect::IdTokenClaims; + +#[derive(Clone, Debug)] +pub struct OidcConfig { + pub issuer_url: IssuerUrl, + pub client_id: String, + pub client_secret: String, + pub app_host: String, + pub scopes: Vec, +} + +impl TryFrom<&AppConfig> for OidcConfig { + type Error = Option<&'static str>; + + fn try_from(config: &AppConfig) -> Result { + let issuer_url = config.oidc_issuer_url.as_ref().ok_or(None)?; + 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 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(), + scopes: config + .oidc_scopes + .split_whitespace() + .map(|s| Scope::new(s.to_string())) + .collect(), + app_host: app_host.clone(), + }) + } +} + +fn get_app_host(config: &AppConfig) -> String { + if let Some(host) = &config.host { + return host.clone(); + } + if let Some(https_domain) = &config.https_domain { + return https_domain.clone(); + } + + let socket_addr = config.listen_on(); + let ip = socket_addr.ip(); + let host = if ip.is_unspecified() || ip.is_loopback() { + format!("localhost:{}", socket_addr.port()) + } else { + socket_addr.to_string() + }; + log::warn!( + "No host or https_domain provided in the configuration, \ + using \"{host}\" as the app host to build the redirect URL. \ + This will only work locally. \ + Disable this warning by providing a value for the \"host\" setting." + ); + host +} + +pub struct OidcState { + pub config: Arc, + pub client: Arc, +} + +pub async fn initialize_oidc_state( + app_config: &AppConfig, +) -> anyhow::Result>> { + let oidc_cfg = match OidcConfig::try_from(app_config) { + Ok(c) => Arc::new(c), + Err(None) => return Ok(None), // OIDC not configured + Err(Some(e)) => return Err(anyhow::anyhow!(e)), + }; + + let http_client = make_http_client(app_config)?; + let provider_metadata = + discover_provider_metadata(&http_client, oidc_cfg.issuer_url.clone()).await?; + let client = make_oidc_client(&oidc_cfg, provider_metadata)?; + + Ok(Some(Arc::new(OidcState { + config: oidc_cfg, + client: Arc::new(client), + }))) +} + +pub struct OidcMiddleware { + oidc_state: Option>, +} + +impl OidcMiddleware { + #[must_use] + pub fn new(app_state: &web::Data) -> Condition { + let oidc_state = app_state.oidc_state.clone(); + Condition::new(oidc_state.is_some(), Self { oidc_state }) + } +} + +async fn discover_provider_metadata( + http_client: &awc::Client, + issuer_url: IssuerUrl, +) -> anyhow::Result { + log::debug!("Discovering provider metadata for {issuer_url}"); + let provider_metadata = openidconnect::core::CoreProviderMetadata::discover_async( + issuer_url, + &AwcHttpClient::from_client(http_client), + ) + .await + .with_context(|| "Failed to discover OIDC provider metadata".to_string())?; + log::debug!("Provider metadata discovered: {provider_metadata:?}"); + Ok(provider_metadata) +} + +impl Transform for OidcMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = OidcService; + type Future = std::future::Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + match &self.oidc_state { + Some(state) => ready(Ok(OidcService::new(service, Arc::clone(state)))), + None => ready(Err(())), + } + } +} + +#[derive(Clone)] +pub struct OidcService { + service: S, + oidc_state: Arc, +} + +impl OidcService { + pub fn new(service: S, oidc_state: Arc) -> Self { + Self { + service, + oidc_state, + } + } + + fn handle_unauthenticated_request( + &self, + request: ServiceRequest, + ) -> LocalBoxFuture, Error>> { + log::debug!("Handling unauthenticated request to {}", request.path()); + if request.path() == SQLPAGE_REDIRECT_URI { + log::debug!("The request is the OIDC callback"); + return self.handle_oidc_callback(request); + } + + log::debug!("Redirecting to OIDC provider"); + + let response = build_auth_provider_redirect_response( + &self.oidc_state.client, + &self.oidc_state.config, + &request, + ); + Box::pin(async move { Ok(request.into_response(response)) }) + } + + fn handle_oidc_callback( + &self, + request: ServiceRequest, + ) -> LocalBoxFuture, Error>> { + let oidc_client = Arc::clone(&self.oidc_state.client); + let oidc_config = Arc::clone(&self.oidc_state.config); + + Box::pin(async move { + let query_string = request.query_string(); + match process_oidc_callback(&oidc_client, 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}"); + let resp = + build_auth_provider_redirect_response(&oidc_client, &oidc_config, &request); + Ok(request.into_response(resp)) + } + } + }) + } +} + +impl Service for OidcService +where + S: Service, Error = Error>, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture>; + + forward_ready!(service); + + 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)) => { + log::trace!("Storing authenticated user info in request extensions: {claims:?}"); + request.extensions_mut().insert(claims); + } + Ok(None) => { + log::trace!("No authenticated user found"); + return self.handle_unauthenticated_request(request); + } + Err(e) => { + log::debug!( + "{:?}", + e.context( + "An auth cookie is present but could not be verified. \ + Redirecting to OIDC provider to re-authenticate." + ) + ); + return self.handle_unauthenticated_request(request); + } + } + let future = self.service.call(request); + Box::pin(async move { + let response = future.await?; + Ok(response) + }) + } +} + +async fn process_oidc_callback( + oidc_client: &OidcClient, + query_string: &str, + request: &ServiceRequest, +) -> anyhow::Result { + let http_client = get_http_client_from_appdata(request)?; + + let state = get_state_from_cookie(request)?; + + let params = Query::::from_query(query_string) + .with_context(|| { + format!( + "{SQLPAGE_REDIRECT_URI}: failed to parse OIDC callback parameters from {query_string}" + ) + })? + .into_inner(); + + if state.csrf_token.secret() != params.state.secret() { + log::debug!("CSRF token mismatch: expected {state:?}, got {params:?}"); + return Err(anyhow!("Invalid CSRF token: {}", params.state.secret())); + } + + log::debug!("Processing OIDC callback with params: {params:?}. Requesting token..."); + let token_response = exchange_code_for_token(oidc_client, http_client, params).await?; + log::debug!("Received token response: {token_response:?}"); + + let mut response = build_redirect_response(state.initial_url); + set_auth_cookie(&mut response, &token_response)?; + Ok(response) +} + +async fn exchange_code_for_token( + oidc_client: &OidcClient, + http_client: &awc::Client, + oidc_callback_params: OidcCallbackParams, +) -> anyhow::Result { + let token_response = oidc_client + .exchange_code(openidconnect::AuthorizationCode::new( + oidc_callback_params.code, + ))? + .request_async(&AwcHttpClient::from_client(http_client)) + .await?; + Ok(token_response) +} + +fn set_auth_cookie( + response: &mut HttpResponse, + token_response: &openidconnect::core::CoreTokenResponse, +) -> 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_str = id_token.to_string(); + log::trace!("Setting auth cookie: {SQLPAGE_AUTH_COOKIE_NAME}=\"{id_token_str}\""); + let cookie = Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, id_token_str) + .secure(true) + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .path("/") + .finish(); + + response.add_cookie(&cookie).unwrap(); + Ok(()) +} + +fn build_auth_provider_redirect_response( + oidc_client: &OidcClient, + oidc_config: &Arc, + request: &ServiceRequest, +) -> HttpResponse { + let AuthUrl { url, params } = build_auth_url(oidc_client, &oidc_config.scopes); + let state_cookie = create_state_cookie(request, params); + HttpResponse::TemporaryRedirect() + .append_header(("Location", url.to_string())) + .cookie(state_cookie) + .body("Redirecting...") +} + +fn build_redirect_response(target_url: String) -> HttpResponse { + HttpResponse::TemporaryRedirect() + .append_header(("Location", target_url)) + .body("Redirecting...") +} + +/// Returns the claims from the ID token in the `SQLPage` auth cookie. +fn get_authenticated_user_info( + oidc_client: &OidcClient, + request: &ServiceRequest, +) -> anyhow::Result> { + let Some(cookie) = request.cookie(SQLPAGE_AUTH_COOKIE_NAME) else { + return Ok(None); + }; + 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 id_token = OidcToken::from_str(&cookie_value) + .with_context(|| format!("Invalid SQLPage auth cookie: {cookie_value:?}"))?; + + let nonce_verifier = |nonce: Option<&Nonce>| check_nonce(nonce, &state.nonce); + let claims: OidcClaims = id_token + .claims(&verifier, nonce_verifier) + .with_context(|| format!("Could not verify the ID token: {cookie_value:?}"))? + .clone(); + log::debug!("The current user is: {claims:?}"); + Ok(Some(claims.clone())) +} + +pub struct AwcHttpClient<'c> { + client: &'c awc::Client, +} + +impl<'c> AwcHttpClient<'c> { + #[must_use] + pub fn from_client(client: &'c awc::Client) -> Self { + Self { client } + } +} + +impl<'c> AsyncHttpClient<'c> for AwcHttpClient<'c> { + type Error = AwcWrapperError; + type Future = + Pin> + 'c>>; + + fn call(&'c self, request: openidconnect::HttpRequest) -> Self::Future { + let client = self.client.clone(); + Box::pin(async move { + execute_oidc_request_with_awc(client, request) + .await + .map_err(AwcWrapperError) + }) + } +} + +async fn execute_oidc_request_with_awc( + client: Client, + request: openidconnect::HttpRequest, +) -> Result>, anyhow::Error> { + let awc_method = awc::http::Method::from_bytes(request.method().as_str().as_bytes())?; + let awc_uri = awc::http::Uri::from_str(&request.uri().to_string())?; + log::debug!("Executing OIDC request: {awc_method} {awc_uri}"); + let mut req = client.request(awc_method, awc_uri); + for (name, value) in request.headers() { + req = req.insert_header((name.as_str(), value.to_str()?)); + } + let (req_head, body) = request.into_parts(); + let mut response = req.send_body(body).await.map_err(|e| { + anyhow!(e.to_string()).context(format!( + "Failed to send request: {} {}", + &req_head.method, &req_head.uri + )) + })?; + let head = response.headers(); + let mut resp_builder = + openidconnect::http::Response::builder().status(response.status().as_u16()); + for (name, value) in head { + resp_builder = resp_builder.header(name.as_str(), value.to_str()?); + } + let body = response + .body() + .await + .with_context(|| format!("Couldnt read from {}", &req_head.uri))?; + log::debug!( + "Received OIDC response with status {}: {}", + response.status(), + String::from_utf8_lossy(&body) + ); + let resp = resp_builder.body(body.to_vec())?; + Ok(resp) +} + +#[derive(Debug)] +pub struct AwcWrapperError(anyhow::Error); + +impl std::fmt::Display for AwcWrapperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} +type OidcClient = openidconnect::core::CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; +impl std::error::Error for AwcWrapperError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.source() + } +} + +fn make_oidc_client( + config: &Arc, + provider_metadata: openidconnect::core::CoreProviderMetadata, +) -> anyhow::Result { + let client_id = openidconnect::ClientId::new(config.client_id.clone()); + let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone()); + + let mut redirect_url = RedirectUrl::new(format!( + "https://{}{}", + config.app_host, SQLPAGE_REDIRECT_URI, + )) + .with_context(|| { + format!( + "Failed to build the redirect URL; invalid app host \"{}\"", + config.app_host + ) + })?; + let needs_http = match redirect_url.url().host() { + Some(openidconnect::url::Host::Domain(domain)) => domain == "localhost", + Some(openidconnect::url::Host::Ipv4(_) | openidconnect::url::Host::Ipv6(_)) => true, + None => false, + }; + if needs_http { + log::debug!("App host seems to be local, changing redirect URL to HTTP"); + redirect_url = RedirectUrl::new(format!( + "http://{}{}", + config.app_host, SQLPAGE_REDIRECT_URI, + ))?; + } + log::info!("OIDC redirect URL for {}: {redirect_url}", config.client_id); + let client = openidconnect::core::CoreClient::from_provider_metadata( + provider_metadata, + client_id, + Some(client_secret), + ) + .set_redirect_uri(redirect_url); + + Ok(client) +} + +#[derive(Debug, Deserialize)] +struct OidcCallbackParams { + code: String, + state: CsrfToken, +} + +struct AuthUrl { + url: Url, + params: AuthUrlParams, +} + +struct AuthUrlParams { + csrf_token: CsrfToken, + nonce: Nonce, +} + +fn build_auth_url(oidc_client: &OidcClient, scopes: &[Scope]) -> AuthUrl { + let nonce_source = Nonce::new_random(); + let hashed_nonce = Nonce::new(hash_nonce(&nonce_source)); + let (url, csrf_token, _nonce) = oidc_client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + || hashed_nonce, + ) + .add_scopes(scopes.iter().cloned()) + .url(); + AuthUrl { + url, + params: AuthUrlParams { + csrf_token, + nonce: nonce_source, + }, + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct OidcLoginState { + /// The URL to redirect to after the login process is complete. + #[serde(rename = "u")] + initial_url: String, + /// The CSRF token to use for the login process. + #[serde(rename = "c")] + csrf_token: CsrfToken, + /// The source nonce to use for the login process. It must be checked against the hash + /// stored in the ID token. + #[serde(rename = "n")] + nonce: Nonce, +} + +fn hash_nonce(nonce: &Nonce) -> String { + use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString}; + let salt = SaltString::generate(&mut OsRng); + // low-cost parameters: oidc tokens are short-lived and the source nonce is high-entropy + let params = argon2::Params::new(8, 1, 1, Some(16)).expect("bug: invalid Argon2 parameters"); + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let hash = argon2 + .hash_password(nonce.secret().as_bytes(), &salt) + .expect("bug: failed to hash nonce"); + hash.to_string() +} + +fn check_nonce(id_token_nonce: Option<&Nonce>, state_nonce: &Nonce) -> Result<(), String> { + match id_token_nonce { + Some(id_token_nonce) => nonce_matches(id_token_nonce, state_nonce), + None => Err("No nonce found in the ID token".to_string()), + } +} + +fn nonce_matches(id_token_nonce: &Nonce, state_nonce: &Nonce) -> Result<(), String> { + log::debug!( + "Checking nonce: {} == {}", + id_token_nonce.secret(), + state_nonce.secret() + ); + let hash = argon2::password_hash::PasswordHash::new(id_token_nonce.secret()).map_err(|e| { + format!( + "Failed to parse state nonce ({}): {e}", + id_token_nonce.secret() + ) + })?; + argon2::password_hash::PasswordVerifier::verify_password( + &argon2::Argon2::default(), + state_nonce.secret().as_bytes(), + &hash, + ) + .map_err(|e| format!("Failed to verify nonce ({}): {e}", state_nonce.secret()))?; + log::debug!("Nonce successfully verified"); + Ok(()) +} + +impl OidcLoginState { + fn new(request: &ServiceRequest, auth_url: AuthUrlParams) -> Self { + Self { + initial_url: request.path().to_string(), + csrf_token: auth_url.csrf_token, + nonce: auth_url.nonce, + } + } +} + +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) + .secure(true) + .http_only(true) + .same_site(actix_web::cookie::SameSite::Lax) + .path("/") + .finish() +} + +fn get_state_from_cookie(request: &ServiceRequest) -> anyhow::Result { + let state_cookie = request.cookie(SQLPAGE_STATE_COOKIE_NAME).with_context(|| { + format!("No {SQLPAGE_STATE_COOKIE_NAME} cookie found for {SQLPAGE_REDIRECT_URI}") + })?; + serde_json::from_str(state_cookie.value()) + .with_context(|| format!("Failed to parse OIDC state from cookie: {state_cookie}")) +}