Skip to content

Commit d5fd554

Browse files
committed
oidc: implement redirects
- Add `host` configuration option for specifying the application's web address in configuration.md and app_config.rs. - Update docker-compose.yaml to include SQLPAGE_HOST and SQLPAGE_OIDC_ISSUER_URL environment variables. - Enhance OIDC middleware to utilize the new `host` setting for redirect URLs and improve cookie handling in oidc.rs.
1 parent 67d2979 commit d5fd554

File tree

5 files changed

+149
-21
lines changed

5 files changed

+149
-21
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

configuration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Here are the available configuration options and their default values:
1313
| `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. |
1414
| `port` | 8080 | Like listen_on, but specifies only the port. |
1515
| `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`.
16+
| `host` | | The web address where your application is accessible (e.g., "myapp.example.com"). Used for login redirects with OIDC. |
1617
| `max_database_pool_connections` | PostgreSQL: 50<BR> MySql: 75<BR> SQLite: 16<BR> MSSQL: 100 | How many simultaneous database connections to open at most |
1718
| `database_connection_idle_timeout_seconds` | SQLite: None<BR> All other: 30 minutes | Automatically close database connections after this period of inactivity |
1819
| `database_connection_max_lifetime_seconds` | SQLite: None<BR> All other: 60 minutes | Always close database connections after this amount of time |
@@ -95,6 +96,23 @@ To set up OIDC, you'll need to:
9596
1. Register your application with an OIDC provider
9697
2. Configure the provider's details in SQLPage
9798

99+
#### Setting Your Application's Address
100+
101+
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:
102+
103+
- Use the `host` setting to specify your application's web address (for example, "myapp.example.com")
104+
- 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`.
105+
106+
Example configuration:
107+
```json
108+
{
109+
"oidc_issuer_url": "https://accounts.google.com",
110+
"oidc_client_id": "your-client-id",
111+
"oidc_client_secret": "your-client-secret",
112+
"host": "myapp.example.com"
113+
}
114+
```
115+
98116
#### Cloud Identity Providers
99117

100118
- **Google**

examples/single sign on/docker-compose.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ services:
1414
- ./sqlpage:/etc/sqlpage
1515
environment:
1616
# OIDC configuration
17+
- SQLPAGE_HOST=localhost:8080
18+
- SQLPAGE_OIDC_ISSUER_URL=http://localhost:8181/realms/sqlpage_demo
1719
- OIDC_AUTHORIZATION_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/auth
1820
- OIDC_TOKEN_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/token
1921
- OIDC_USERINFO_ENDPOINT=http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/userinfo
@@ -28,6 +30,9 @@ services:
2830
# SQLPage configuration
2931
- RUST_LOG=sqlpage=debug
3032
network_mode: host
33+
depends_on:
34+
keycloak:
35+
condition: service_healthy
3136

3237
keycloak:
3338
build:
@@ -39,3 +44,9 @@ services:
3944
volumes:
4045
- ./keycloak-configuration.json:/opt/keycloak/data/import/realm.json
4146
network_mode: host
47+
healthcheck:
48+
test: ["CMD-SHELL", "/opt/keycloak/bin/kcadm.sh get realms/sqlpage_demo --server http://localhost:8181 --realm master --user admin --password admin || exit 1"]
49+
interval: 10s
50+
timeout: 2s
51+
retries: 5
52+
start_period: 5s

src/app_config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ pub struct AppConfig {
222222
/// and will automatically request a certificate from Let's Encrypt
223223
/// using the ACME protocol (requesting a TLS-ALPN-01 challenge).
224224
pub https_domain: Option<String>,
225+
226+
/// The hostname where your application is publicly accessible (e.g., "myapp.example.com").
227+
/// This is used for OIDC redirect URLs. If not set, https_domain will be used instead.
228+
pub host: Option<String>,
225229

226230
/// The email address to use when requesting a certificate from Let's Encrypt.
227231
/// Defaults to `contact@<https_domain>`.

src/webserver/oidc.rs

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,55 @@ use crate::{app_config::AppConfig, AppState};
44
use actix_web::{
55
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
66
middleware::Condition,
7-
web, Error,
7+
web, Error, HttpResponse,
88
};
9-
use anyhow::anyhow;
9+
use anyhow::{anyhow, Context};
1010
use awc::Client;
11-
use openidconnect::{AsyncHttpClient, IssuerUrl};
11+
use openidconnect::{
12+
core::{CoreAuthDisplay, CoreAuthenticationFlow},
13+
AsyncHttpClient, CsrfToken, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet,
14+
EndpointSet, IssuerUrl, Nonce, RedirectUrl, Scope,
15+
};
1216

1317
use super::http_client::make_http_client;
1418

19+
const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth";
20+
const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback";
21+
1522
#[derive(Clone, Debug)]
1623
pub struct OidcConfig {
1724
pub issuer_url: IssuerUrl,
1825
pub client_id: String,
1926
pub client_secret: String,
20-
pub scopes: String,
27+
pub app_host: String,
28+
pub scopes: Vec<Scope>,
2129
}
2230

2331
impl TryFrom<&AppConfig> for OidcConfig {
2432
type Error = Option<&'static str>;
2533

2634
fn try_from(config: &AppConfig) -> Result<Self, Self::Error> {
2735
let issuer_url = config.oidc_issuer_url.as_ref().ok_or(None)?;
28-
let client_secret = config
29-
.oidc_client_secret
36+
let client_secret = config.oidc_client_secret.as_ref().ok_or(Some(
37+
"The \"oidc_client_secret\" setting is required to authenticate with the OIDC provider",
38+
))?;
39+
40+
let app_host = config
41+
.host
3042
.as_ref()
31-
.ok_or(Some("Missing oidc_client_secret"))?;
43+
.or_else(|| config.https_domain.as_ref())
44+
.ok_or(Some("The \"host\" or \"https_domain\" setting is required to build the OIDC redirect URL"))?;
3245

3346
Ok(Self {
3447
issuer_url: issuer_url.clone(),
3548
client_id: config.oidc_client_id.clone(),
3649
client_secret: client_secret.clone(),
37-
scopes: config.oidc_scopes.clone(),
50+
scopes: config
51+
.oidc_scopes
52+
.split_whitespace()
53+
.map(|s| Scope::new(s.to_string()))
54+
.collect(),
55+
app_host: app_host.clone(),
3856
})
3957
}
4058
}
@@ -80,13 +98,12 @@ async fn discover_provider_metadata(
8098
Ok(provider_metadata)
8199
}
82100

83-
impl<S, B> Transform<S, ServiceRequest> for OidcMiddleware
101+
impl<S> Transform<S, ServiceRequest> for OidcMiddleware
84102
where
85-
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
103+
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error> + 'static,
86104
S::Future: 'static,
87-
B: 'static,
88105
{
89-
type Response = ServiceResponse<B>;
106+
type Response = ServiceResponse<BoxBody>;
90107
type Error = Error;
91108
type InitError = ();
92109
type Transform = OidcService<S>;
@@ -115,7 +132,7 @@ pub struct OidcService<S> {
115132
service: S,
116133
app_state: web::Data<AppState>,
117134
config: Arc<OidcConfig>,
118-
provider_metadata: openidconnect::core::CoreProviderMetadata,
135+
client: OidcClient,
119136
}
120137

121138
impl<S> OidcService<S> {
@@ -125,40 +142,80 @@ impl<S> OidcService<S> {
125142
config: Arc<OidcConfig>,
126143
) -> anyhow::Result<Self> {
127144
let issuer_url = config.issuer_url.clone();
145+
let provider_metadata = discover_provider_metadata(&app_state.config, issuer_url).await?;
146+
let client: OidcClient = make_oidc_client(&config, provider_metadata)?;
128147
Ok(Self {
129148
service,
130149
app_state: web::Data::clone(app_state),
131150
config,
132-
provider_metadata: discover_provider_metadata(&app_state.config, issuer_url).await?,
151+
client,
133152
})
134153
}
154+
155+
fn build_auth_url(&self, request: &ServiceRequest) -> String {
156+
let (auth_url, csrf_token, nonce) = self
157+
.client
158+
.authorize_url(
159+
CoreAuthenticationFlow::AuthorizationCode,
160+
CsrfToken::new_random,
161+
Nonce::new_random,
162+
)
163+
// Set the desired scopes.
164+
.add_scopes(self.config.scopes.iter().cloned())
165+
.url();
166+
auth_url.to_string()
167+
}
135168
}
136169

137170
type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
171+
use actix_web::body::BoxBody;
138172

139-
impl<S, B> Service<ServiceRequest> for OidcService<S>
173+
impl<S> Service<ServiceRequest> for OidcService<S>
140174
where
141-
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
175+
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error>,
142176
S::Future: 'static,
143-
B: 'static,
144177
{
145-
type Response = ServiceResponse<B>;
178+
type Response = ServiceResponse<BoxBody>;
146179
type Error = Error;
147180
type Future = LocalBoxFuture<Result<Self::Response, Self::Error>>;
148181

149182
forward_ready!(service);
150183

151184
fn call(&self, request: ServiceRequest) -> Self::Future {
152185
log::debug!("Started OIDC middleware with config: {:?}", self.config);
153-
let future = self.service.call(request);
186+
match get_sqlpage_auth_cookie(&request) {
187+
Some(cookie) => {
188+
log::trace!("Found SQLPage auth cookie: {cookie}");
189+
}
190+
None => {
191+
log::trace!("No SQLPage auth cookie found, redirecting to login");
192+
let auth_url = self.build_auth_url(&request);
154193

194+
return Box::pin(async move {
195+
Ok(request.into_response(build_redirect_response(auth_url)))
196+
});
197+
}
198+
}
199+
let future = self.service.call(request);
155200
Box::pin(async move {
156201
let response = future.await?;
157202
Ok(response)
158203
})
159204
}
160205
}
161206

207+
fn build_redirect_response(auth_url: String) -> HttpResponse {
208+
HttpResponse::TemporaryRedirect()
209+
.append_header(("Location", auth_url))
210+
.body("Redirecting to the login page.")
211+
}
212+
213+
fn get_sqlpage_auth_cookie(request: &ServiceRequest) -> Option<String> {
214+
let cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME)?;
215+
log::error!("TODO: actually check the validity of the cookie");
216+
Some(cookie.value().to_string())
217+
}
218+
162219
pub struct AwcHttpClient {
163220
client: Client,
164221
}
@@ -226,5 +283,43 @@ impl std::fmt::Display for StringError {
226283
std::fmt::Display::fmt(&self.0, f)
227284
}
228285
}
229-
286+
type OidcClient = openidconnect::core::CoreClient<
287+
EndpointSet,
288+
EndpointNotSet,
289+
EndpointNotSet,
290+
EndpointNotSet,
291+
EndpointMaybeSet,
292+
EndpointMaybeSet,
293+
>;
230294
impl std::error::Error for StringError {}
295+
296+
fn make_oidc_client(
297+
config: &Arc<OidcConfig>,
298+
provider_metadata: openidconnect::core::CoreProviderMetadata,
299+
) -> anyhow::Result<OidcClient> {
300+
let client_id = openidconnect::ClientId::new(config.client_id.clone());
301+
let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone());
302+
303+
let local_hosts = ["localhost", "127.0.0.1", "::1"];
304+
let is_localhost = local_hosts.iter().any(|host| {
305+
config.app_host.starts_with(host)
306+
&& config
307+
.app_host
308+
.get(host.len()..(host.len() + 1))
309+
.is_none_or(|c| c == ":")
310+
});
311+
let redirect_url = RedirectUrl::new(format!(
312+
"{}://{}{}",
313+
if is_localhost { "http" } else { "https" },
314+
config.app_host,
315+
SQLPAGE_REDIRECT_URI,
316+
))?;
317+
let client = openidconnect::core::CoreClient::from_provider_metadata(
318+
provider_metadata,
319+
client_id,
320+
Some(client_secret),
321+
)
322+
.set_redirect_uri(redirect_url);
323+
324+
Ok(client)
325+
}

0 commit comments

Comments
 (0)