a (hacky, wip) multi-tenant oidc-terminating reverse proxy, written in anger on top of pingora
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}