use std::collections::HashMap; use std::sync::Arc; use color_eyre::eyre::Context as _; use http::status::StatusCode; use pingora::prelude::*; use tokio::sync::Mutex as TokioMutex; use url::Url; use crate::config; use crate::httputil::{internal_error, internal_error_from, status_error, status_error_from}; use crate::oauth; /// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#cookie_prefixes pub const SESSION_COOKIE_NAME: &str = "__Host-Http-oauth-session"; /// active session user information pub struct UserInfo { /// when this session expires, from the id token `exp` claim pub expires_at: jiff::Timestamp, /// all other non-default claims attached to the id token pub claims: HashMap, } /// in-progress auth flow state #[derive(Clone)] // only needed cause papaya pub struct InProgressAuth { /// the code verifier, whence the code challenge was derived pub code_verifier: String, /// the original path we were trying to go to on this domain, /// for redirection once the auth flow is over pub original_path: String, } /// cache of auth server metadata and associated bits pub struct MetadataCache { /// the metadata itself pub metadata: oauth::metadata::AuthServerMetadata, /// the fetched, parsed jwks info /// "none" means validation was disabled in our config, _NOT_ "we couldn't fetch the jwks" pub jws_verifier: Option, } /// overall auth info pub struct Info { /// the raw config from our config file pub config: config::format::Oidc, // needs to be tokio because we need to hold it across an await point /// cache of auth server metadata pub meta_cache: TokioMutex>>, /// the current in-progress authorization flows, bound to states submitted to the auth serve pub auth_states: papaya::HashMap, /// the currently active sessions, by id from the session id cookie pub sessions: papaya::HashMap, /// the root domain pub domain: String, /// the oauth client secret pub client_secret: String, /// the signing key used to sign session cookies pub cookie_signing_key: ed25519_dalek::SigningKey, } impl Info { /// clear the metadata cache pub async fn clear_metadata_cache(&self) { *self.meta_cache.lock().await = None; } /// get the metadata, or cache it (and the accompanying jwks data if needed) if it hasn't get /// been fetched pub async fn get_or_cache_metadata(&self) -> Result> { let mut cache = self.meta_cache.lock().await; if let Some(ref meta) = *cache { return Ok(meta.clone()); } let discovery_url = { let url = Url::parse(&self.config.discovery_url_base) .map_err(internal_error_from("invalid discovery url"))?; oauth::metadata::oidc_discovery_uri(&url) .map_err(internal_error_from("invalid discovery url suffix"))? }; let meta: oauth::metadata::AuthServerMetadata = { let resp = reqwest::Client::new() .get(discovery_url.as_str()) .header(http::header::ACCEPT, "application/json") .send() .await .map_err(internal_error_from("unable to fetch oauth metadata doc"))?; if !resp.status().is_success() { return Err(status_error( "unable to fetch discovery info", ErrorSource::Internal, StatusCode::SERVICE_UNAVAILABLE, )()); } resp.json().await.map_err(internal_error_from( "unable to deserialize oauth metadata doc", ))? }; meta.generally_as_expected().map_err(internal_error_from( "auth server not generally as expected/required", ))?; let jws_verifier = if self.config.validate_with_jwk { if !meta .id_token_signing_alg_values_supported .as_ref() .is_some_and(|s| s.contains(&oauth::metadata::SigningAlgValue::ES256)) { return Err(internal_error("es256 signing not supported by endpoint")()); } let Some(jwks_uri) = &meta.jwks_uri else { return Err(internal_error( "jwks not available or es256 signing not supported by endpoint", )()); }; let resp = reqwest::Client::new() .get(jwks_uri.as_str()) .header(http::header::ACCEPT, "application/json") .send() .await .map_err(status_error_from( "unable to fetch jwks", ErrorSource::Internal, StatusCode::SERVICE_UNAVAILABLE, ))?; if !resp.status().is_success() { return Err(status_error( "unable to fetch jwks", ErrorSource::Internal, StatusCode::SERVICE_UNAVAILABLE, )()); } let jwks: compact_jwt::JwkKeySet = resp .json() .await .map_err(internal_error_from("unable to deserialize jwks"))?; // per oidc discovery v1 section 3, this either contains only signing keys, or has // keys with a `use` option. we're going to choose to only support 1 key per use // here and require a use anyway. this whole thing is so poorly specified. the jwt // ecosystem really is a tire fire Some( jwks.keys .iter() .filter_map(|key| { let compact_jwt::Jwk::EC { use_: r#use, .. } = &key else { return None; }; if !r#use .as_ref() .is_some_and(|r#use| r#use == &compact_jwt::JwkUse::Sig) { return None; } compact_jwt::JwsEs256Verifier::try_from(key).ok() }) .next() .ok_or_else(internal_error("no sig keys availabe from jwks"))?, ) } else { None }; Ok(cache .insert(Arc::new(MetadataCache { metadata: meta, jws_verifier, })) .clone()) } pub fn from_config(config: config::format::Oidc, domain: String) -> color_eyre::Result { let mut rng = rand::rngs::OsRng; // TODO(feature): check & warn on permissions here? let client_secret = std::fs::read_to_string(&config.client_secret_path) .context("reading client secret")? .trim() .to_string(); Ok(Self { config, meta_cache: TokioMutex::new(None), auth_states: Default::default(), sessions: Default::default(), client_secret, cookie_signing_key: ed25519_dalek::SigningKey::generate(&mut rng), domain, }) } }