Browse Source

feat: allow overriding sso endpoint

Signed-off-by: Nico Feulner <nico.feulner@gmail.com>
pull/7694/head
Nico Feulner 3 weeks ago
parent
commit
ef38027a7f
No known key found for this signature in database GPG Key ID: 735B78461570C16C
  1. 13
      .env.template
  2. 24
      src/config.rs
  3. 149
      src/sso_client.rs

13
.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}/.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 # 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). ## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit).
# SSO_SCOPES="email profile" # SSO_SCOPES="email profile"

24
src/config.rs

@ -449,6 +449,7 @@ macro_rules! make_config {
"smtp_host", "smtp_host",
"smtp_username", "smtp_username",
"sso_authority", "sso_authority",
"sso_internal_endpoint",
"sso_callback_path", "sso_callback_path",
"sso_client_id", "sso_client_id",
]; ];
@ -829,6 +830,14 @@ make_config! {
sso_client_secret: Pass, true, def, String::new(); sso_client_secret: Pass, true, def, String::new();
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
sso_authority: String, true, def, String::new(); 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) /// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
sso_scopes: String, true, def, "email profile".to_owned(); sso_scopes: String, true, def, "email profile".to_owned();
/// Authorization request extra parameters /// 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_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_internal_sso_redirect_url(&cfg.sso_callback_path)?;
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; 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<open
} }
} }
#[allow(clippy::ref_option)]
fn validate_sso_url_opt(opt: &Option<String>, 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( fn validate_sso_master_password_policy(
sso_master_password_policy: Option<&String>, sso_master_password_policy: Option<&String>,
) -> Result<Option<serde_json::Value>, Error> { ) -> Result<Option<serde_json::Value>, Error> {

149
src/sso_client.rs

@ -3,9 +3,9 @@ use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::Laz
use openidconnect::{ use openidconnect::{
AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode,
AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields,
EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, JsonWebKeySetUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType,
StandardTokenResponse, Scope, StandardErrorResponse, StandardTokenResponse, TokenUrl, UserInfoUrl,
core::{ core::{
CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim,
CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm,
@ -77,12 +77,94 @@ impl OidcHttpClient {
} }
} }
fn rewrite_oidc_url(public: &str, internal: &str, original: &str) -> Option<String> {
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<T, E: std::fmt::Display>(
raw: Option<String>,
env_name: &str,
parse: impl FnOnce(String) -> Result<T, E>,
) -> Option<T> {
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 { impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
type Error = HttpClientError<reqwest::Error>; type Error = HttpClientError<reqwest::Error>;
type Future = Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + Send + Sync + 'c>>; type Future = Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + Send + Sync + 'c>>;
fn call(&'c self, request: HttpRequest) -> Self::Future { fn call(&'c self, request: HttpRequest) -> Self::Future {
Box::pin(async move { 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| { let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| {
debug!("Request failed {e:?}"); debug!("Request failed {e:?}");
Box::new(e) Box::new(e)
@ -116,11 +198,13 @@ impl Client {
Ok(client) => 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}")), Err(err) => err!(format!("Failed to discover OpenID provider: {err}")),
Ok(metadata) => metadata, Ok(metadata) => metadata,
}; };
provider_metadata = apply_endpoint_overrides(provider_metadata);
let auth_methods: Option<HashSet<CoreClientAuthMethod>> = provider_metadata let auth_methods: Option<HashSet<CoreClientAuthMethod>> = provider_metadata
.token_endpoint_auth_methods_supported() .token_endpoint_auth_methods_supported()
.map(|v| v.iter().map(ToOwned::to_owned).collect()); .map(|v| v.iter().map(ToOwned::to_owned).collect());
@ -346,3 +430,60 @@ impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestE
self 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}"
);
}
}
}

Loading…
Cancel
Save