From 5b06e609d18c81fd323cbdb540df260e7445aa17 Mon Sep 17 00:00:00 2001 From: Solly Ross Date: Sun, 15 Feb 2026 15:28:40 -0800 Subject: [PATCH] fix compound claim-headers this reworks how claim-headers are serialized -- previously, they were spit out as json, now you choose how to join compound types with config, and scalars are spit out in their "raw" form (i.e. just like their json serialized form, but with strings being unquoted). null values are skipped, and cause equivalent map-items to be skipped. --- README.md | 21 +++++------ src/config/format.proto | 30 +++++++++++++++- src/gateway.rs | 80 ++++++++++++++++++++++++++++++----------- src/httputil.rs | 40 +++++++++++++++++++++ 4 files changed, 137 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index e9c167b..f285ec5 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,6 @@ documentation, so i'd suggest starting there. Here's a quick example config: ```textproto -# bind on ipv4 -bind_to_tcp { - addr: '0.0.0.0:8443' - tls { - cert_path: "/var/lib/secrets/serving.cert" - key_path: "/var/lib/secrets/serving.key" - } -} # bind on ipv6 bind_to_tcp { addr: '[::]:8443' @@ -83,12 +75,17 @@ domains { # these claims are mapped to headers claims { claim_to_header { - key: "group_names" - value: "X-Idp-Groups" + claim: "files_groups" + header: "X-Idp-Groups" + # since this is an array, concatenate it with commans + # before sending it to fileparty + serialize_as { + join_array_items_with: "," + } } claim_to_header { - key: "name" - value: "X-Idp-User" + claim: "name" + header: "X-Idp-User" } }, } diff --git a/src/config/format.proto b/src/config/format.proto index 6968cc2..b295f12 100644 --- a/src/config/format.proto +++ b/src/config/format.proto @@ -129,10 +129,38 @@ message Scopes { // information on how to process returned claims message Claims { + // maps a claim to a header name & value + message ClaimToHeader { + // the name of the claim + string claim = 1; + // the name to the header + string header = 2; + // how to serialize compound (object, array) typed-claims + optional Serialization serialize_as = 3; + } + // map the given claims to a header in the backend request // // headers specified here with no corresponding claim value will be wiped - map claim_to_header = 1; + repeated ClaimToHeader claim_to_header = 1; + + // how to serialize compound (object, array) typed-claims + // + // this doesn't cover _deeply_ nested identical objects (e.g. + // arrays-of-arrays), or selecting/projecting, but covers most usecases. in + // theory i could implement, say, jq or something but that feels like + // overkill. + // + // values that are `null` will always be skipped, and map entries with a null + // value with also be skipped. + message Serialization { + // how to join keys to values (e.g. `=` means `key=value`) + optional string join_keys_and_values_with = 1; + // how to join key-value pairs to each other (e.g. `; ` means `key=value; key=value`) + optional string join_key_value_pairs_with = 2; + // how to join array items to each other (e.g. `, ` means `item, item, item`) + optional string join_array_items_with = 3; + } } // a standard https backend diff --git a/src/gateway.rs b/src/gateway.rs index 2cb4554..255ae1e 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -12,6 +12,7 @@ use pingora::prelude::*; use pingora::protocols::tls::CaType; use url::Url; +use crate::config::format::claims::ClaimToHeader; use crate::gateway::oidc::{InProgressAuth, SESSION_COOKIE_NAME, UserInfo}; use crate::httputil::{internal_error, internal_error_from, status_error, status_error_from}; use crate::oauth::auth_code_flow; @@ -94,32 +95,36 @@ impl AuthGateway { .get(http::header::HOST) .expect("we had to have this to look up our backend") .clone(); - req.headers.insert(name, val); + req.insert_header(name, val)?; } if let Some(header) = &info.headers.x_forwarded_for && let Some(addr) = &remote_addr { let name = HeaderName::from_bytes(header.as_bytes()) .map_err(internal_error_from("invalid claim-to-header header name"))?; - let mut val = req + let val = req .headers .get("x-forwarded-for") .map(|v| v.as_bytes()) - .unwrap_or(b"") - .to_owned(); - val.extend(b","); - val.extend(addr.as_bytes()); + .filter(|v| !v.is_empty()) + .map(|v| v.to_owned()) + .map(|mut v| { + v.extend(b","); + v.extend(addr.as_bytes()); + v + }) + .unwrap_or(addr.as_bytes().to_owned()); let val = HeaderValue::from_bytes(&val) .map_err(internal_error_from("invalid remote-addr header value"))?; - req.headers.insert(name, val); + req.insert_header(name, val)?; } if let Some(header) = &info.headers.x_forwarded_proto { let name = HeaderName::from_bytes(header.as_bytes()) .map_err(internal_error_from("invalid claim-to-header header name"))?; - req.headers.insert( + req.insert_header( name, HeaderValue::from_static(if is_https { "https" } else { "http" }), - ); + )?; } if let Some(header) = &info.headers.remote_addr && let Some(addr) = &remote_addr @@ -128,7 +133,11 @@ impl AuthGateway { .map_err(internal_error_from("invalid claim-to-header header name"))?; let val = HeaderValue::from_str(addr) .map_err(internal_error_from("invalid remote-addr header value"))?; - req.headers.insert(name, val); + req.insert_header(name, val)?; + } + + for header in &info.headers.always_clear { + req.remove_header(header); } Ok(()) @@ -158,7 +167,12 @@ impl AuthGateway { .filter(|sess| sess.expires_at > jiff::Timestamp::now()) { if let Some(claim_map) = &auth_info.config.claims { - for (claim, header) in &claim_map.claim_to_header { + for ClaimToHeader { + claim, + header, + serialize_as: _, // pre-serialized + } in &claim_map.claim_to_header + { match valid_session.claims.get(claim) { Some(val) => { let val = HeaderValue::from_bytes(val.as_bytes()) @@ -166,9 +180,11 @@ impl AuthGateway { let name = HeaderName::from_bytes(header.as_bytes()).map_err( internal_error_from("invalid claim-to-header header name"), )?; - req.headers.insert(name, val) + req.insert_header(name, val)?; + } + None => { + req.remove_header(header); } - None => req.headers.remove(header), }; } } @@ -437,13 +453,15 @@ impl AuthGateway { // - exp (must expire in the future) if id_token .iss + .as_ref() .is_none_or(|iss| iss != meta_cache.metadata.issuer.as_str()) { return Err(internal_error("issuer mismatch on id token")()); } if id_token .aud - .is_none_or(|aud| aud != auth_info.config.client_id) + .as_ref() + .is_none_or(|aud| *aud != auth_info.config.client_id) { return Err(internal_error("audience mismatch on id token")()); } @@ -460,15 +478,35 @@ impl AuthGateway { return Ok(ControlFlow::Break(())); } + // reserialize, and then deserialize, to get the hard-coded claims out + let id_token: HashMap<_, _> = serde_json::from_value( + serde_json::to_value(id_token).expect("just deserialized from json"), + ) + .expect("just round-tripped it"); let user_info = UserInfo { expires_at, - claims: id_token - .claims - .into_iter() - // [`serde_json::Value`] implements display that's just "semi-infallible - // serialize" - .map(|(k, v)| (k, v.to_string())) - .collect(), + claims: 'claims: { + let Some(claims_map) = + auth_info.config.claims.as_ref().map(|c| &c.claim_to_header) + else { + break 'claims Default::default(); + }; + id_token + .into_iter() + // [`serde_json::Value`] implements display that's just "semi-infallible + // serialize" + // ideally this'd be a hashmap in rust, but it shouldn't be too large, + // so it should be fine + .filter_map(|(k, v)| { + let claim_cfg = + claims_map.iter().find(|claim_cfg| claim_cfg.claim == k)?; + Some((k, v, claim_cfg)) + }) + .filter_map(|(k, v, cfg)| { + Some((k, httputil::serialize_claim(v, cfg.serialize_as.as_ref())?)) + }) + .collect() + }, }; let expiry = user_info.expires_at; let mut rng = rand::rngs::OsRng; diff --git a/src/httputil.rs b/src/httputil.rs index ad0c7f8..012c5c0 100644 --- a/src/httputil.rs +++ b/src/httputil.rs @@ -116,3 +116,43 @@ pub fn cookie_jar(req: &'_ RequestHeader) -> Result, +) -> Option { + let array_sep = cfg + .and_then(|c| c.join_array_items_with.as_deref()) + .unwrap_or(""); + let key_and_value_sep = cfg + .and_then(|c| c.join_keys_and_values_with.as_deref()) + .unwrap_or(""); + let map_item_sep = cfg + .and_then(|c| c.join_key_value_pairs_with.as_deref()) + .unwrap_or(""); + + Some(match val { + serde_json::Value::Null => return None, + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(num) => num.to_string(), + serde_json::Value::String(s) => s, + serde_json::Value::Array(values) => values + .into_iter() + .filter_map(|val| serialize_claim(val, cfg)) + // T_T no intersperse yet + .collect::>() + .join(array_sep), + serde_json::Value::Object(map) => map + .into_iter() + .filter_map(|(key, val)| { + Some(format!( + "{key}{sep}{val}", + sep = key_and_value_sep, + val = serialize_claim(val, cfg)?, + )) + }) + .collect::>() + .join(map_item_sep), + }) +} -- knotmirror 0.0.1