diff --git a/CHANGELOG.md b/CHANGELOG.md index f71b0cba..beff7b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ ``` - The file-based routing system was improved. Now, requests to `/xxx` redirect to `/xxx/` only if `/xxx/index.sql` exists. - fix: When single sign on is enabled, and an anonymous user visits a page with URL parameters, the user is correctly redirected to the page with the parameters after login. +- Added support for reading custom claims in JWT tokens generated by OIDC providers. This means that you can configure your Single-Sign-On provider to store custom pieces of information about your users, such as roles or permissions, and use them in your SQL queries in SQLPage. +- Implement OIDC provider metadata refresh. This fixes a bug where leaving a SQLPage instance running with SSO enabled would cause infinite redirect loops on login after some time. Since most providers rotate their signing keys regularly and sqlpage only fetched the metadata once at startup, the only way to fix the issue was to restart SQLPage manually. ## v0.35.2 - Fix a bug with zero values being displayed with a non-zero height in stacked bar charts. diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index ca7a15a4..b7fdb8ac 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; use std::future::ready; +use std::rc::Rc; +use std::time::{Duration, Instant}; use std::{future::Future, pin::Pin, str::FromStr, sync::Arc}; use crate::webserver::http_client::get_http_client_from_appdata; @@ -15,12 +17,22 @@ use actix_web::{ use anyhow::{anyhow, Context}; use awc::Client; use chrono::Utc; +use openidconnect::core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, + CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreRevocableToken, + CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, +}; use openidconnect::{ core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, TokenResponse, }; +use openidconnect::{ + EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse, + StandardTokenResponse, +}; use serde::{Deserialize, Serialize}; +use tokio::sync::{RwLock, RwLockReadGuard}; use super::http_client::make_http_client; @@ -29,6 +41,8 @@ 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"; +const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60); +const OIDC_CLIENT_MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(transparent)] @@ -99,7 +113,7 @@ impl OidcConfig { fn create_id_token_verifier<'a>( &'a self, oidc_client: &'a OidcClient, - ) -> openidconnect::IdTokenVerifier<'a, openidconnect::core::CoreJsonWebKey> { + ) -> IdTokenVerifier<'a, CoreJsonWebKey> { oidc_client .id_token_verifier() .set_other_audience_verifier_fn(self.additional_audience_verifier.as_fn()) @@ -130,9 +144,81 @@ fn get_app_host(config: &AppConfig) -> String { host } +pub struct ClientWithTime { + client: OidcClient, + last_update: Instant, +} + pub struct OidcState { pub config: OidcConfig, - pub client: OidcClient, + client: RwLock, +} + +impl OidcState { + pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result { + let http_client = make_http_client(&app_config)?; + let client = build_oidc_client(&oidc_cfg, &http_client).await?; + + Ok(Self { + config: oidc_cfg, + client: RwLock::new(ClientWithTime { + client, + last_update: Instant::now(), + }), + }) + } + + async fn refresh(&self, service_request: &ServiceRequest) { + // Obtain a write lock to prevent concurrent OIDC client refreshes. + let mut write_guard = self.client.write().await; + match build_oidc_client_from_appdata(&self.config, service_request).await { + Ok(http_client) => { + *write_guard = ClientWithTime { + client: http_client, + last_update: Instant::now(), + } + } + Err(e) => log::error!("Failed to refresh OIDC client: {e:#}"), + } + } + + /// Refreshes the OIDC client from the provider metadata URL if it has expired. + /// Most providers update their signing keys periodically. + pub async fn refresh_if_expired(&self, service_request: &ServiceRequest) { + if self.client.read().await.last_update.elapsed() > OIDC_CLIENT_MAX_REFRESH_INTERVAL { + self.refresh(service_request).await; + } + } + + /// When an authentication error is encountered, refresh the OIDC client info faster + pub async fn refresh_on_error(&self, service_request: &ServiceRequest) { + if self.client.read().await.last_update.elapsed() > OIDC_CLIENT_MIN_REFRESH_INTERVAL { + self.refresh(service_request).await; + } + } + + /// Gets a reference to the oidc client, potentially generating a new one if needed + pub async fn get_client(&self) -> RwLockReadGuard<'_, OidcClient> { + RwLockReadGuard::map( + self.client.read().await, + |ClientWithTime { client, .. }| client, + ) + } + + /// Validate and decode the claims of an OIDC token, without refreshing the client. + async fn get_token_claims( + &self, + id_token: OidcToken, + state: Option<&OidcLoginState>, + ) -> anyhow::Result { + let client = &self.get_client().await; + let verifier = self.config.create_id_token_verifier(client); + let nonce_verifier = |nonce: Option<&Nonce>| check_nonce(nonce, state); + let claims: OidcClaims = id_token + .into_claims(&verifier, nonce_verifier) + .map_err(|e| anyhow::anyhow!("Could not verify the ID token: {}", e))?; + Ok(claims) + } } pub async fn initialize_oidc_state( @@ -144,15 +230,27 @@ pub async fn initialize_oidc_state( 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::new(oidc_cfg, app_config.clone()).await?, + ))) +} + +async fn build_oidc_client_from_appdata( + cfg: &OidcConfig, + req: &ServiceRequest, +) -> anyhow::Result { + let http_client = get_http_client_from_appdata(req)?; + build_oidc_client(cfg, http_client).await +} - Ok(Some(Arc::new(OidcState { - config: oidc_cfg, - client, - }))) +async fn build_oidc_client( + oidc_cfg: &OidcConfig, + http_client: &Client, +) -> anyhow::Result { + let issuer_url = oidc_cfg.issuer_url.clone(); + let provider_metadata = discover_provider_metadata(http_client, issuer_url.clone()).await?; + let client = make_oidc_client(oidc_cfg, provider_metadata)?; + Ok(client) } pub struct OidcMiddleware { @@ -203,7 +301,7 @@ where #[derive(Clone)] pub struct OidcService { - service: S, + service: Rc, oidc_state: Arc, } @@ -214,86 +312,98 @@ where { pub fn new(service: S, oidc_state: Arc) -> Self { Self { - service, + service: Rc::new(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); - } +enum MiddlewareResponse { + Forward(ServiceRequest), + Respond(ServiceResponse), +} - if self.oidc_state.config.is_public_path(request.path()) { - log::debug!( - "The request path {} is not in a public path, skipping OIDC authentication", - request.path() - ); - return Box::pin(self.service.call(request)); +async fn handle_request( + oidc_state: &OidcState, + request: ServiceRequest, +) -> actix_web::Result { + log::trace!("Started OIDC middleware request handling"); + oidc_state.refresh_if_expired(&request).await; + match get_authenticated_user_info(oidc_state, &request).await { + Ok(Some(claims)) => { + if request.path() != SQLPAGE_REDIRECT_URI { + log::trace!("Storing authenticated user info in request extensions: {claims:?}"); + request.extensions_mut().insert(claims); + return Ok(MiddlewareResponse::Forward(request)); + } + let response = handle_authenticated_oidc_callback(request); + Ok(MiddlewareResponse::Respond(response)) } + Ok(None) => { + log::trace!("No authenticated user found"); + handle_unauthenticated_request(oidc_state, request).await + } + Err(e) => { + log::debug!("An auth cookie is present but could not be verified. Redirecting to OIDC provider to re-authenticate. {e:?}"); + oidc_state.refresh_on_error(&request).await; + handle_unauthenticated_request(oidc_state, request).await + } + } +} - log::debug!("Redirecting to OIDC provider"); +async fn handle_unauthenticated_request( + oidc_state: &OidcState, + request: ServiceRequest, +) -> actix_web::Result { + log::debug!("Handling unauthenticated request to {}", request.path()); + if request.path() == SQLPAGE_REDIRECT_URI { + log::debug!("The request is the OIDC callback"); + let response = handle_oidc_callback(oidc_state, request).await?; + return Ok(MiddlewareResponse::Respond(response)); + } - 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)) }) + if oidc_state.config.is_public_path(request.path()) { + return Ok(MiddlewareResponse::Forward(request)); } - fn handle_oidc_callback( - &self, - request: ServiceRequest, - ) -> LocalBoxFuture, Error>> { - let oidc_state = Arc::clone(&self.oidc_state); + log::debug!("Redirecting to OIDC provider"); - Box::pin(async move { - let query_string = request.query_string(); - match process_oidc_callback( - &oidc_state.client, - &oidc_state.config, - query_string, - &request, - ) - .await - { - Ok(response) => Ok(request.into_response(response)), - Err(e) => { - log::error!("Failed to process OIDC callback with params {query_string}: {e}"); - let resp = build_auth_provider_redirect_response( - &oidc_state.client, - &oidc_state.config, - &request, - ); - Ok(request.into_response(resp)) - } - } - }) + let initial_url = request.uri().to_string(); + let response = build_auth_provider_redirect_response(oidc_state, initial_url).await; + Ok(MiddlewareResponse::Respond(request.into_response(response))) +} + +async fn handle_oidc_callback( + oidc_state: &OidcState, + request: ServiceRequest, +) -> actix_web::Result { + let query_string = request.query_string(); + match process_oidc_callback(oidc_state, query_string, &request).await { + Ok(response) => Ok(request.into_response(response)), + Err(e) => { + let redirect_url = + get_state_from_cookie(&request).map_or_else(|_| "/".into(), |s| s.initial_url); + log::error!("Failed to process OIDC callback. Refreshing oidc provider metadata, then redirecting to {redirect_url}: {e:#}"); + oidc_state.refresh_on_error(&request).await; + let resp = build_auth_provider_redirect_response(oidc_state, redirect_url).await; + Ok(request.into_response(resp)) + } } } /// When an user has already authenticated (potentially in another tab), we ignore the callback and redirect to the initial URL. -fn handle_authenticated_oidc_callback( - request: ServiceRequest, -) -> LocalBoxFuture, Error>> { +fn handle_authenticated_oidc_callback(request: ServiceRequest) -> ServiceResponse { let redirect_url = match get_state_from_cookie(&request) { Ok(state) => state.initial_url, Err(_) => "/".to_string(), }; log::debug!("OIDC callback received for authenticated user. Redirecting to {redirect_url}"); - let response = request.into_response(build_redirect_response(redirect_url)); - Box::pin(ready(Ok(response))) + request.into_response(build_redirect_response(redirect_url)) } impl Service for OidcService where - S: Service, Error = Error>, + S: Service, Error = Error> + 'static, S::Future: 'static, { type Response = ServiceResponse; @@ -303,50 +413,26 @@ where forward_ready!(service); fn call(&self, request: ServiceRequest) -> Self::Future { - log::trace!("Started OIDC middleware request handling"); - - let oidc_client = &self.oidc_state.client; - let oidc_config = &self.oidc_state.config; - match get_authenticated_user_info(oidc_client, oidc_config, &request) { - Ok(Some(claims)) => { - if request.path() == SQLPAGE_REDIRECT_URI { - return handle_authenticated_oidc_callback(request); - } - 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); + let srv = Rc::clone(&self.service); + let oidc_state = Arc::clone(&self.oidc_state); Box::pin(async move { - let response = future.await?; - Ok(response) + match handle_request(&oidc_state, request).await { + Ok(MiddlewareResponse::Respond(response)) => Ok(response), + Ok(MiddlewareResponse::Forward(request)) => srv.call(request).await, + Err(err) => Err(err), + } }) } } async fn process_oidc_callback( - oidc_client: &OidcClient, - oidc_config: &OidcConfig, + oidc_state: &OidcState, query_string: &str, request: &ServiceRequest, ) -> anyhow::Result { let http_client = get_http_client_from_appdata(request)?; - let state = get_state_from_cookie(request)?; + let state = get_state_from_cookie(request).context("Failed to read oidc state cookie")?; let params = Query::::from_query(query_string) .with_context(|| { @@ -361,14 +447,17 @@ async fn process_oidc_callback( return Err(anyhow!("Invalid CSRF token: {}", params.state.secret())); } + let client = oidc_state.get_client().await; log::debug!("Processing OIDC callback with params: {params:?}. Requesting token..."); - let token_response = exchange_code_for_token(oidc_client, http_client, params).await?; + let token_response = exchange_code_for_token(&client, http_client, params).await?; log::debug!("Received token response: {token_response:?}"); let redirect_target = validate_redirect_url(state.initial_url); log::info!("Redirecting to {redirect_target} after a successful login"); let mut response = build_redirect_response(redirect_target); - set_auth_cookie(&mut response, &token_response, oidc_client, oidc_config)?; + set_auth_cookie(&mut response, &token_response, oidc_state) + .await + .context("Failed to set auth cookie")?; Ok(response) } @@ -376,21 +465,21 @@ async fn exchange_code_for_token( oidc_client: &OidcClient, http_client: &awc::Client, oidc_callback_params: OidcCallbackParams, -) -> anyhow::Result { +) -> anyhow::Result { let token_response = oidc_client .exchange_code(openidconnect::AuthorizationCode::new( oidc_callback_params.code, ))? .request_async(&AwcHttpClient::from_client(http_client)) - .await?; + .await + .context("Failed to exchange code for token")?; Ok(token_response) } -fn set_auth_cookie( +async fn set_auth_cookie( response: &mut HttpResponse, - token_response: &openidconnect::core::CoreTokenResponse, - oidc_client: &OidcClient, - oidc_config: &OidcConfig, + token_response: &OidcTokenResponse, + oidc_state: &OidcState, ) -> anyhow::Result<()> { let access_token = token_response.access_token(); log::trace!("Received access token: {}", access_token.secret()); @@ -398,10 +487,8 @@ fn set_auth_cookie( .id_token() .context("No ID token found in the token response. You may have specified an oauth2 provider that does not support OIDC.")?; - let id_token_verifier = oidc_config.create_id_token_verifier(oidc_client); - let nonce_verifier = |_nonce: Option<&Nonce>| Ok(()); // The nonce will be verified in request handling - let claims = id_token.claims(&id_token_verifier, nonce_verifier)?; - let expiration = claims.expiration(); + let claims_res = oidc_state.get_token_claims(id_token.clone(), None).await; + let expiration = claims_res.context("Parsing ID token claims")?.expiration(); let max_age_seconds = expiration.signed_duration_since(Utc::now()).num_seconds(); let id_token_str = id_token.to_string(); @@ -425,13 +512,13 @@ fn set_auth_cookie( Ok(()) } -fn build_auth_provider_redirect_response( - oidc_client: &OidcClient, - oidc_config: &OidcConfig, - request: &ServiceRequest, +async fn build_auth_provider_redirect_response( + oidc_state: &OidcState, + initial_url: String, ) -> HttpResponse { - let AuthUrl { url, params } = build_auth_url(oidc_client, &oidc_config.scopes); - let state_cookie = create_state_cookie(request, params); + let AuthUrl { url, params } = build_auth_url(oidc_state).await; + let state = OidcLoginState::new(initial_url, params); + let state_cookie = create_state_cookie(&state); HttpResponse::TemporaryRedirect() .append_header(("Location", url.to_string())) .cookie(state_cookie) @@ -445,26 +532,20 @@ fn build_redirect_response(target_url: String) -> HttpResponse { } /// Returns the claims from the ID token in the `SQLPage` auth cookie. -fn get_authenticated_user_info( - oidc_client: &OidcClient, - config: &OidcConfig, +async fn get_authenticated_user_info( + oidc_state: &OidcState, 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 = config.create_id_token_verifier(oidc_client); 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(); + let state = get_state_from_cookie(request)?; + log::debug!("Verifying id token: {id_token:?}"); + let claims = oidc_state.get_token_claims(id_token, Some(&state)).await?; log::debug!("The current user is: {claims:?}"); Ok(Some(claims)) } @@ -540,7 +621,30 @@ impl std::fmt::Display for AwcWrapperError { std::fmt::Display::fmt(&self.0, f) } } -type OidcClient = openidconnect::core::CoreClient< + +type OidcTokenResponse = StandardTokenResponse< + IdTokenFields< + OidcAdditionalClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, +>; + +type OidcClient = openidconnect::Client< + OidcAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + OidcTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, EndpointSet, EndpointNotSet, EndpointNotSet, @@ -548,6 +652,7 @@ type OidcClient = openidconnect::core::CoreClient< EndpointMaybeSet, EndpointMaybeSet, >; + impl std::error::Error for AwcWrapperError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.0.source() @@ -584,12 +689,9 @@ fn make_oidc_client( ))?; } 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); + let client = + OidcClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(redirect_url); Ok(client) } @@ -610,10 +712,12 @@ struct AuthUrlParams { nonce: Nonce, } -fn build_auth_url(oidc_client: &OidcClient, scopes: &[Scope]) -> AuthUrl { +async fn build_auth_url(oidc_state: &OidcState) -> AuthUrl { let nonce_source = Nonce::new_random(); let hashed_nonce = Nonce::new(hash_nonce(&nonce_source)); - let (url, csrf_token, _nonce) = oidc_client + let scopes = &oidc_state.config.scopes; + let client_lock = oidc_state.get_client().await; + let (url, csrf_token, _nonce) = client_lock .authorize_url( CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, @@ -630,20 +734,6 @@ fn build_auth_url(oidc_client: &OidcClient, scopes: &[Scope]) -> AuthUrl { } } -#[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); @@ -656,9 +746,15 @@ fn hash_nonce(nonce: &Nonce) -> String { hash.to_string() } -fn check_nonce(id_token_nonce: Option<&Nonce>, state_nonce: &Nonce) -> Result<(), String> { +fn check_nonce( + id_token_nonce: Option<&Nonce>, + login_state: Option<&OidcLoginState>, +) -> Result<(), String> { + let Some(state) = login_state else { + return Ok(()); // No login state, no nonce to check + }; match id_token_nonce { - Some(id_token_nonce) => nonce_matches(id_token_nonce, state_nonce), + Some(id_token_nonce) => nonce_matches(id_token_nonce, &state.nonce), None => Err("No nonce found in the ID token".to_string()), } } @@ -685,19 +781,32 @@ fn nonce_matches(id_token_nonce: &Nonce, state_nonce: &Nonce) -> Result<(), Stri Ok(()) } +#[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, +} + impl OidcLoginState { - fn new(request: &ServiceRequest, auth_url: AuthUrlParams) -> Self { + fn new(initial_url: String, auth_url: AuthUrlParams) -> Self { Self { - initial_url: request.uri().to_string(), + initial_url, 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(); +fn create_state_cookie(login_state: &OidcLoginState) -> Cookie { + let state_json = serde_json::to_string(login_state).unwrap(); Cookie::build(SQLPAGE_STATE_COOKIE_NAME, state_json) .secure(true) .http_only(true) @@ -739,7 +848,7 @@ impl AudienceVerifier { /// Validate that a redirect URL is safe to use (prevents open redirect attacks) fn validate_redirect_url(url: String) -> String { - if url.starts_with('/') && !url.starts_with("//") { + if url.starts_with('/') && !url.starts_with("//") && !url.starts_with(SQLPAGE_REDIRECT_URI) { return url; } log::warn!("Refusing to redirect to {url}");