use async_trait::async_trait;
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation};
use reqwest::Client;
use std::{sync::Arc, time::Duration};
use serde::Deserialize;
use tokio::sync::RwLock;
use globset::{Glob, GlobSet, GlobSetBuilder};
use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, OctetKeyParameters};
use crate::{core::{ProxyError, ProxyRequest}, security::{SecurityProvider, SecurityStage}, ProxyResponse};
pub const CLAIMS_ATTRIBUTE: &str = "oidc-claims";
const BEARER: &str = "bearer ";
const JWKS_REFRESH: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RouteRuleConfig {
pub methods: Vec<String>,
pub path: String,
}
#[derive(Debug)]
struct RouteRule {
methods: Vec<String>,
paths: GlobSet,
}
impl RouteRule {
fn matches(&self, method: &str, path: &str) -> bool {
(self.methods.iter().any(|m| m == "*" || m == method))
&& self.paths.is_match(path)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct OidcConfig {
#[serde(rename = "issuer-uri")]
pub issuer_uri: String,
#[serde(default)]
pub aud: Option<String>,
#[serde(default, rename = "shared-secret")]
pub shared_secret: Option<String>,
#[serde(default, rename = "bypass-routes")]
pub bypass: Vec<RouteRuleConfig>,
}
fn jwk_to_decoding_key(jwk: &Jwk) -> Result<DecodingKey, ProxyError> {
match &jwk.algorithm {
AlgorithmParameters::RSA(rsa) => {
Ok(DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?)
}
AlgorithmParameters::EllipticCurve(ec) => {
Ok(DecodingKey::from_ec_components(&ec.x, &ec.y)?)
}
AlgorithmParameters::OctetKey(OctetKeyParameters { value, .. }) => {
Ok(DecodingKey::from_ed_components(value)?)
}
_ => Err(ProxyError::SecurityError("unsupported key type".into())),
}
}
#[derive(Debug)]
pub struct OidcProvider {
issuer: String,
aud: Option<String>,
shared_secret: Option<String>,
jwks_uri: String,
jwks: Arc<RwLock<Option<JwkSet>>>,
last_refresh: Arc<RwLock<tokio::time::Instant>>,
http: Client,
rules: Vec<RouteRule>,
}
impl OidcProvider {
pub async fn discover(cfg: OidcConfig) -> Result<Self, ProxyError> {
let client = Client::builder()
.user_agent("foxy/oidc")
.build()?;
#[derive(Deserialize)]
struct Discovery { jwks_uri: String }
let meta: Discovery = client
.get(&cfg.issuer_uri)
.send()
.await?
.error_for_status()?
.json()
.await?;
let mut rules = Vec::with_capacity(cfg.bypass.len());
for raw in cfg.bypass {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new(&raw.path)?);
rules.push(RouteRule {
methods: raw.methods.iter().map(|m| m.to_ascii_uppercase()).collect(),
paths: builder.build()?,
});
}
Ok(Self {
issuer: cfg
.issuer_uri
.trim_end_matches("/.well-known/openid-configuration")
.to_owned(),
aud: cfg.aud,
shared_secret: cfg.shared_secret,
jwks_uri: meta.jwks_uri,
jwks: Arc::new(RwLock::new(None)),
last_refresh: Arc::new(RwLock::new(
tokio::time::Instant::now() - JWKS_REFRESH,
)),
http: client,
rules,
})
}
async fn refresh_jwks(&self) -> Result<(), ProxyError> {
let now = tokio::time::Instant::now();
if now.duration_since(*self.last_refresh.read().await) < JWKS_REFRESH {
return Ok(());
}
let set = self
.http
.get(&self.jwks_uri)
.send()
.await?
.error_for_status()?
.json::<JwkSet>()
.await?;
*self.jwks.write().await = Some(set);
*self.last_refresh.write().await = now;
Ok(())
}
fn validate_std_claims(&self, claims: &serde_json::Value) -> Result<(), ProxyError> {
if claims["iss"] != self.issuer {
return Err(ProxyError::SecurityError("bad issuer".into()));
}
if let Some(ref aud) = self.aud {
let ok = claims["aud"]
.as_str()
.map(|a| a == aud)
.unwrap_or(false);
if !ok {
return Err(ProxyError::SecurityError("bad audience".into()));
}
}
Ok(())
}
#[inline]
fn is_bypassed(&self, method: &str, path: &str) -> bool {
self.rules.iter().any(|r| r.matches(method, path))
}
}
#[async_trait]
impl SecurityProvider for OidcProvider {
fn name(&self) -> &str { "OidcProvider" }
fn stage(&self) -> SecurityStage { SecurityStage::Pre }
async fn pre(&self, mut req: ProxyRequest) -> Result<ProxyRequest, ProxyError> {
if self.is_bypassed(&req.method.to_string(), &req.path) {
return Ok(req);
}
let auth = req
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| ProxyError::SecurityError("missing Authorization".into()))?
.to_ascii_lowercase();
if !auth.starts_with(BEARER) {
return Err(ProxyError::SecurityError("unsupported auth scheme".into()));
}
let token = auth.trim_start_matches(BEARER).trim();
let header = decode_header(token)?;
let allowed_algs = [
Algorithm::RS256,
Algorithm::RS384,
Algorithm::RS512,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
Algorithm::ES256,
Algorithm::ES384,
Algorithm::EdDSA,
Algorithm::HS256,
Algorithm::HS384,
Algorithm::HS512,
];
if !allowed_algs.contains(&header.alg) {
return Err(ProxyError::SecurityError("alg not allowed".into()));
}
let decoding_key = match header.alg {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
let secret = self.shared_secret.as_deref().ok_or_else(|| {
ProxyError::SecurityError("shared-secret not configured".into())
})?;
DecodingKey::from_secret(secret.as_bytes())
}
_ => {
self.refresh_jwks().await?;
let kid = header
.kid
.ok_or_else(|| ProxyError::SecurityError("missing kid".into()))?;
let jwks_guard = self.jwks.read().await;
let set = jwks_guard
.as_ref()
.ok_or_else(|| ProxyError::SecurityError("no JWKS cache".into()))?;
let jwk = set
.find(&kid)
.ok_or_else(|| ProxyError::SecurityError("unknown kid".into()))?;
jwk_to_decoding_key(jwk)?
}
};
let mut validation = Validation::new(header.alg);
validation.set_required_spec_claims(&["exp", "iss"]);
let data = decode::<serde_json::Value>(token, &decoding_key, &validation)?;
self.validate_std_claims(&data.claims)?;
{
let mut ctx = req.context.write().await;
ctx.attributes.insert(CLAIMS_ATTRIBUTE.into(), data.claims);
}
Ok(req)
}
}