From ef38027a7f0c6a7316237b59e2ccee04ab804b5f Mon Sep 17 00:00:00 2001 From: Nico Feulner Date: Fri, 4 Sep 2026 14:25:57 +0200 Subject: [PATCH] feat: allow overriding sso endpoint Signed-off-by: Nico Feulner --- .env.template | 13 ++++ src/config.rs | 24 ++++++++ src/sso_client.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/.env.template b/.env.template index 5f6f374c..735c421a 100644 --- a/.env.template +++ b/.env.template @@ -532,6 +532,19 @@ ## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse # SSO_AUTHORITY=https://auth.example.com +## Optional internal base URL for server-side OIDC requests (discovery, token, userinfo, JWKS) +## - If set, discovery and any discovered endpoints that share SSO_AUTHORITY prefix are rewritten to this URL. +## - Keeps the public issuer for validation and browser redirects. Useful for K8s/Docker where Vaultwarden and IdP share a private network. +## - Example (cluster-internal): SSO_INTERNAL_ENDPOINT=http://sso:8080 +## - Example (Keycloak with realm): SSO_AUTHORITY=https://sso.example.com/realms/myrealm, SSO_INTERNAL_ENDPOINT=http://keycloak:8080/realms/myrealm +# SSO_INTERNAL_ENDPOINT=http://sso:8080 + +## Optional explicit overrides for discovered endpoints (full URLs, take precedence over discovery and SSO_INTERNAL_ENDPOINT) +## - Names match ProviderMetadata fields prefixed with SSO_ (e.g. jwks_uri -> SSO_JWKS_URI, token_endpoint -> SSO_TOKEN_ENDPOINT) +# SSO_TOKEN_ENDPOINT=http://sso:8080/token +# SSO_USERINFO_ENDPOINT=http://sso:8080/userinfo +# SSO_JWKS_URI=http://sso:8080/jwks + ## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit). # SSO_SCOPES="email profile" diff --git a/src/config.rs b/src/config.rs index 87bea195..c467fea1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -449,6 +449,7 @@ macro_rules! make_config { "smtp_host", "smtp_username", "sso_authority", + "sso_internal_endpoint", "sso_callback_path", "sso_client_id", ]; @@ -829,6 +830,14 @@ make_config! { sso_client_secret: Pass, true, def, String::new(); /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) sso_authority: String, true, def, String::new(); + /// Internal Endpoint |> Optional internal base URL for server-side OIDC requests (discovery, token, userinfo, JWKS). If set, requests to SSO_AUTHORITY are rewritten to this URL. Also rewrites discovered endpoints that share SSO_AUTHORITY prefix. + sso_internal_endpoint: String, true, option; + /// Token endpoint override |> Optional full URL override for token_endpoint (e.g. https://sso.example.com/token). If set, discovered value is ignored. + sso_token_endpoint: String, true, option; + /// UserInfo endpoint override |> Optional full URL override for userinfo_endpoint + sso_userinfo_endpoint: String, true, option; + /// JWKS URI override |> Optional full URL override for jwks_uri + sso_jwks_uri: String, true, option; /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) sso_scopes: String, true, def, "email profile".to_owned(); /// Authorization request extra parameters @@ -1112,6 +1121,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } validate_internal_sso_issuer_url(&cfg.sso_authority)?; + validate_sso_url_opt(&cfg.sso_internal_endpoint, "sso_internal_endpoint")?; + validate_sso_url_opt(&cfg.sso_token_endpoint, "sso_token_endpoint")?; + validate_sso_url_opt(&cfg.sso_userinfo_endpoint, "sso_userinfo_endpoint")?; + validate_sso_url_opt(&cfg.sso_jwks_uri, "sso_jwks_uri")?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } @@ -1308,6 +1321,17 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result, name: &str) -> Result<(), Error> { + if let Some(url) = opt + && !url.trim().is_empty() + && Url::parse(url).is_err() + { + err!(format!("Invalid {name} URL ({url}): invalid URL")) + } + Ok(()) +} + fn validate_sso_master_password_policy( sso_master_password_policy: Option<&String>, ) -> Result, Error> { diff --git a/src/sso_client.rs b/src/sso_client.rs index bc766586..7dc17498 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -3,9 +3,9 @@ use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::Laz use openidconnect::{ AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, - EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, - OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, - StandardTokenResponse, + EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, + JsonWebKeySetUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, + Scope, StandardErrorResponse, StandardTokenResponse, TokenUrl, UserInfoUrl, core::{ CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, @@ -77,12 +77,94 @@ impl OidcHttpClient { } } +fn rewrite_oidc_url(public: &str, internal: &str, original: &str) -> Option { + let public = public.trim().trim_end_matches('/'); + let internal = internal.trim().trim_end_matches('/'); + if public.is_empty() || internal.is_empty() || public.eq_ignore_ascii_case(internal) { + return None; + } + // Parse and normalize via Url to handle case, default ports, and trailing slashes robustly. + let public_url = Url::parse(public).ok()?; + let internal_url = Url::parse(internal).ok()?; + let orig_url = Url::parse(original).ok()?; + + let public_norm = public_url.as_str().trim_end_matches('/'); + let internal_norm = internal_url.as_str().trim_end_matches('/'); + let orig_str = orig_url.as_str(); + + let suffix = orig_str.strip_prefix(public_norm)?; + let new_url = format!("{internal_norm}{suffix}"); + Url::parse(&new_url).ok().map(|_| new_url) +} + +fn is_explicit_override(url: &str) -> bool { + [CONFIG.sso_token_endpoint(), CONFIG.sso_userinfo_endpoint(), CONFIG.sso_jwks_uri()] + .into_iter() + .flatten() + .any(|e| e.trim() == url) +} + +fn maybe_rewrite_oidc_request(request: HttpRequest) -> HttpRequest { + let Some(internal) = CONFIG.sso_internal_endpoint().filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_owned()) + else { + return request; + }; + let orig = request.uri().to_string(); + // Explicit full-URL overrides take precedence; don't rewrite those even if they share SSO_AUTHORITY prefix. + if is_explicit_override(&orig) { + return request; + } + let Some(new_url) = rewrite_oidc_url(&CONFIG.sso_authority(), &internal, &orig) else { + return request; + }; + debug!("Rewriting OIDC request {orig} -> {new_url}"); + let (mut parts, body) = request.into_parts(); + parts.uri = new_url.parse().unwrap_or(parts.uri); + http::Request::from_parts(parts, body) +} + +fn explicit_url( + raw: Option, + env_name: &str, + parse: impl FnOnce(String) -> Result, +) -> Option { + let v = raw.filter(|s| !s.trim().is_empty())?; + let trimmed = v.trim().to_owned(); + match parse(trimmed.clone()) { + Ok(u) => { + debug!("Overriding {env_name} with {trimmed}"); + Some(u) + } + Err(e) => { + warn!("Invalid {env_name} '{trimmed}' – ignoring: {e}"); + None + } + } +} + +fn apply_endpoint_overrides(mut metadata: CoreProviderMetadata) -> CoreProviderMetadata { + // SSO_INTERNAL_ENDPOINT is handled at the HTTP layer (maybe_rewrite_oidc_request) + // so discovered URLs are rewritten on-the-fly. Here we only apply explicit full-URL overrides, + // which take precedence and are stored in metadata. + if let Some(u) = explicit_url(CONFIG.sso_token_endpoint(), "SSO_TOKEN_ENDPOINT", TokenUrl::new) { + metadata = metadata.set_token_endpoint(Some(u)); + } + if let Some(u) = explicit_url(CONFIG.sso_userinfo_endpoint(), "SSO_USERINFO_ENDPOINT", UserInfoUrl::new) { + metadata = metadata.set_userinfo_endpoint(Some(u)); + } + if let Some(u) = explicit_url(CONFIG.sso_jwks_uri(), "SSO_JWKS_URI", JsonWebKeySetUrl::new) { + metadata = metadata.set_jwks_uri(u); + } + metadata +} + impl<'c> AsyncHttpClient<'c> for OidcHttpClient { type Error = HttpClientError; type Future = Pin> + Send + Sync + 'c>>; fn call(&'c self, request: HttpRequest) -> Self::Future { Box::pin(async move { + let request = maybe_rewrite_oidc_request(request); let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| { debug!("Request failed {e:?}"); Box::new(e) @@ -116,11 +198,13 @@ impl Client { Ok(client) => client, }; - let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { + let mut provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), Ok(metadata) => metadata, }; + provider_metadata = apply_endpoint_overrides(provider_metadata); + let auth_methods: Option> = provider_metadata .token_endpoint_auth_methods_supported() .map(|v| v.iter().map(ToOwned::to_owned).collect()); @@ -346,3 +430,60 @@ impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestE self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_oidc_url_cases() { + let cases: &[(&str, &str, &str, Option<&str>)] = &[ + ( + "https://sso.example.com", + "http://sso:8080", + "https://sso.example.com/.well-known/openid-configuration", + Some("http://sso:8080/.well-known/openid-configuration"), + ), + ( + "https://sso.example.com/realms/test", + "http://keycloak:8080/realms/test", + "https://sso.example.com/realms/test/.well-known/openid-configuration", + Some("http://keycloak:8080/realms/test/.well-known/openid-configuration"), + ), + ("https://sso.example.com", "http://127.0.0.1:8081", "https://sso.example.com/token", Some("http://127.0.0.1:8081/token")), + ( + "https://sso.example.com", + "http://sso:8080", + "https://sso.example.com/token?foo=bar&baz=qux", + Some("http://sso:8080/token?foo=bar&baz=qux"), + ), + ("https://sso.example.com", "http://sso:8080", "https://other.example.com/token", None), + ( + "https://sso.example.com", + "https://sso.example.com", + "https://sso.example.com/.well-known/openid-configuration", + None, + ), + ( + "https://sso.example.com/", + "http://sso:8080/", + "https://sso.example.com/.well-known/openid-configuration", + Some("http://sso:8080/.well-known/openid-configuration"), + ), + ( + "https://sso.example.com:8443", + "http://sso:8080", + "https://sso.example.com:8443/.well-known/openid-configuration", + Some("http://sso:8080/.well-known/openid-configuration"), + ), + ]; + + for (public, internal, original, expected) in cases { + assert_eq!( + rewrite_oidc_url(public, internal, original), + expected.map(|s| s.to_owned()), + "public={public} internal={internal} original={original}" + ); + } + } +}