a (hacky, wip) multi-tenant oidc-terminating reverse proxy, written in anger on top of pingora
at main 193 lines 7.4 kB view raw
1use std::collections::HashMap; 2use std::sync::Arc; 3 4use color_eyre::eyre::Context as _; 5use http::status::StatusCode; 6use pingora::prelude::*; 7use tokio::sync::Mutex as TokioMutex; 8use url::Url; 9 10use crate::config; 11use crate::httputil::{internal_error, internal_error_from, status_error, status_error_from}; 12use crate::oauth; 13 14/// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#cookie_prefixes 15pub const SESSION_COOKIE_NAME: &str = "__Host-Http-oauth-session"; 16 17/// active session user information 18pub struct UserInfo { 19 /// when this session expires, from the id token `exp` claim 20 pub expires_at: jiff::Timestamp, 21 /// all other non-default claims attached to the id token 22 pub claims: HashMap<String, String>, 23} 24 25/// in-progress auth flow state 26#[derive(Clone)] // only needed cause papaya 27pub struct InProgressAuth { 28 /// the code verifier, whence the code challenge was derived 29 pub code_verifier: String, 30 /// the original path we were trying to go to on this domain, 31 /// for redirection once the auth flow is over 32 pub original_path: String, 33} 34 35/// cache of auth server metadata and associated bits 36pub struct MetadataCache { 37 /// the metadata itself 38 pub metadata: oauth::metadata::AuthServerMetadata, 39 /// the fetched, parsed jwks info 40 /// "none" means validation was disabled in our config, _NOT_ "we couldn't fetch the jwks" 41 pub jws_verifier: Option<compact_jwt::JwsEs256Verifier>, 42} 43 44/// overall auth info 45pub struct Info { 46 /// the raw config from our config file 47 pub config: config::format::Oidc, 48 // needs to be tokio because we need to hold it across an await point 49 /// cache of auth server metadata 50 pub meta_cache: TokioMutex<Option<Arc<MetadataCache>>>, 51 /// the current in-progress authorization flows, bound to states submitted to the auth serve 52 pub auth_states: papaya::HashMap<uuid::Uuid, InProgressAuth>, 53 /// the currently active sessions, by id from the session id cookie 54 pub sessions: papaya::HashMap<u64, UserInfo>, 55 /// the root domain 56 pub domain: String, 57 /// the oauth client secret 58 pub client_secret: String, 59 60 /// the signing key used to sign session cookies 61 pub cookie_signing_key: ed25519_dalek::SigningKey, 62} 63impl Info { 64 /// clear the metadata cache 65 pub async fn clear_metadata_cache(&self) { 66 *self.meta_cache.lock().await = None; 67 } 68 /// get the metadata, or cache it (and the accompanying jwks data if needed) if it hasn't get 69 /// been fetched 70 pub async fn get_or_cache_metadata(&self) -> Result<Arc<MetadataCache>> { 71 let mut cache = self.meta_cache.lock().await; 72 if let Some(ref meta) = *cache { 73 return Ok(meta.clone()); 74 } 75 let discovery_url = { 76 let url = Url::parse(&self.config.discovery_url_base) 77 .map_err(internal_error_from("invalid discovery url"))?; 78 oauth::metadata::oidc_discovery_uri(&url) 79 .map_err(internal_error_from("invalid discovery url suffix"))? 80 }; 81 82 let meta: oauth::metadata::AuthServerMetadata = { 83 let resp = reqwest::Client::new() 84 .get(discovery_url.as_str()) 85 .header(http::header::ACCEPT, "application/json") 86 .send() 87 .await 88 .map_err(internal_error_from("unable to fetch oauth metadata doc"))?; 89 90 if !resp.status().is_success() { 91 return Err(status_error( 92 "unable to fetch discovery info", 93 ErrorSource::Internal, 94 StatusCode::SERVICE_UNAVAILABLE, 95 )()); 96 } 97 resp.json().await.map_err(internal_error_from( 98 "unable to deserialize oauth metadata doc", 99 ))? 100 }; 101 102 meta.generally_as_expected().map_err(internal_error_from( 103 "auth server not generally as expected/required", 104 ))?; 105 106 let jws_verifier = if self.config.validate_with_jwk { 107 if !meta 108 .id_token_signing_alg_values_supported 109 .as_ref() 110 .is_some_and(|s| s.contains(&oauth::metadata::SigningAlgValue::ES256)) 111 { 112 return Err(internal_error("es256 signing not supported by endpoint")()); 113 } 114 115 let Some(jwks_uri) = &meta.jwks_uri else { 116 return Err(internal_error( 117 "jwks not available or es256 signing not supported by endpoint", 118 )()); 119 }; 120 let resp = reqwest::Client::new() 121 .get(jwks_uri.as_str()) 122 .header(http::header::ACCEPT, "application/json") 123 .send() 124 .await 125 .map_err(status_error_from( 126 "unable to fetch jwks", 127 ErrorSource::Internal, 128 StatusCode::SERVICE_UNAVAILABLE, 129 ))?; 130 if !resp.status().is_success() { 131 return Err(status_error( 132 "unable to fetch jwks", 133 ErrorSource::Internal, 134 StatusCode::SERVICE_UNAVAILABLE, 135 )()); 136 } 137 let jwks: compact_jwt::JwkKeySet = resp 138 .json() 139 .await 140 .map_err(internal_error_from("unable to deserialize jwks"))?; 141 // per oidc discovery v1 section 3, this either contains only signing keys, or has 142 // keys with a `use` option. we're going to choose to only support 1 key per use 143 // here and require a use anyway. this whole thing is so poorly specified. the jwt 144 // ecosystem really is a tire fire 145 Some( 146 jwks.keys 147 .iter() 148 .filter_map(|key| { 149 let compact_jwt::Jwk::EC { use_: r#use, .. } = &key else { 150 return None; 151 }; 152 if !r#use 153 .as_ref() 154 .is_some_and(|r#use| r#use == &compact_jwt::JwkUse::Sig) 155 { 156 return None; 157 } 158 compact_jwt::JwsEs256Verifier::try_from(key).ok() 159 }) 160 .next() 161 .ok_or_else(internal_error("no sig keys availabe from jwks"))?, 162 ) 163 } else { 164 None 165 }; 166 167 Ok(cache 168 .insert(Arc::new(MetadataCache { 169 metadata: meta, 170 jws_verifier, 171 })) 172 .clone()) 173 } 174 175 pub fn from_config(config: config::format::Oidc, domain: String) -> color_eyre::Result<Self> { 176 let mut rng = rand::rngs::OsRng; 177 // TODO(feature): check & warn on permissions here? 178 let client_secret = std::fs::read_to_string(&config.client_secret_path) 179 .context("reading client secret")? 180 .trim() 181 .to_string(); 182 Ok(Self { 183 config, 184 meta_cache: TokioMutex::new(None), 185 auth_states: Default::default(), 186 sessions: Default::default(), 187 client_secret, 188 189 cookie_signing_key: ed25519_dalek::SigningKey::generate(&mut rng), 190 domain, 191 }) 192 } 193}