foxy/security/
oidc.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! OpenID-Connect bearer-token provider.
6//!
7//! Supported algs   : HS256 / 384 / 512  · RS256 / 384 / 512 · PS256 / 384 / 512
8//!                    ES256 / 384        · EdDSA (Ed25519)
9//! Bypass rules     : glob-style paths + method list, evaluated before token checks
10//! HMAC secret      : optional `shared-secret` in config (required for HS* algs)
11//! JWKS refresh     : lazy + every 30 min ± key-rotation retry
12
13use async_trait::async_trait;
14use jsonwebtoken::{decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation};
15use reqwest::Client;
16use std::{sync::Arc, time::Duration};
17use serde::Deserialize;
18use tokio::sync::RwLock;
19use globset::{Glob, GlobSet, GlobSetBuilder};
20use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, OctetKeyParameters};
21use crate::{core::{ProxyError, ProxyRequest}, security::{SecurityProvider, SecurityStage}, ProxyResponse};
22
23pub const CLAIMS_ATTRIBUTE: &str = "oidc-claims";
24const BEARER: &str = "bearer ";
25const JWKS_REFRESH: Duration = Duration::from_secs(30 * 60);
26
27#[derive(Debug, Clone, serde::Deserialize)]
28pub struct RouteRuleConfig {
29    pub methods: Vec<String>,
30    pub path: String,
31}
32
33#[derive(Debug)]
34struct RouteRule {
35    methods: Vec<String>,
36    paths: GlobSet,
37}
38
39impl RouteRule {
40    fn matches(&self, method: &str, path: &str) -> bool {
41        (self.methods.iter().any(|m| m == "*" || m == method))
42            && self.paths.is_match(path)
43    }
44}
45
46/// Top-level OIDC section under `"security_chain"` in config.
47#[derive(Debug, Clone, Deserialize)]
48pub struct OidcConfig {
49    #[serde(rename = "issuer-uri")]
50    pub issuer_uri: String,
51
52    #[serde(default)]
53    pub aud: Option<String>,
54
55    /// Only required for HS* algorithms.
56    #[serde(default, rename = "shared-secret")]
57    pub shared_secret: Option<String>,
58
59    #[serde(default, rename = "bypass-routes")]
60    pub bypass: Vec<RouteRuleConfig>,
61}
62
63/// Convert any supported JWK → DecodingKey.
64fn jwk_to_decoding_key(jwk: &Jwk) -> Result<DecodingKey, ProxyError> {
65    match &jwk.algorithm {
66        AlgorithmParameters::RSA(rsa) => {
67            Ok(DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?)
68        }
69        AlgorithmParameters::EllipticCurve(ec) => {
70            Ok(DecodingKey::from_ec_components(&ec.x, &ec.y)?)
71        }
72        AlgorithmParameters::OctetKey(OctetKeyParameters { value, .. }) => {
73            Ok(DecodingKey::from_ed_components(value)?)
74        }
75        _ => Err(ProxyError::SecurityError("unsupported key type".into())),
76    }
77}
78
79#[derive(Debug)]
80pub struct OidcProvider {
81    issuer: String,
82    aud: Option<String>,
83    shared_secret: Option<String>,
84
85    jwks_uri: String,
86    jwks: Arc<RwLock<Option<JwkSet>>>,
87    last_refresh: Arc<RwLock<tokio::time::Instant>>,
88    http: Client,
89
90    rules: Vec<RouteRule>,
91}
92
93impl OidcProvider {
94    /* ---------- factory -------------------------------------------------- */
95
96    pub async fn discover(cfg: OidcConfig) -> Result<Self, ProxyError> {
97        // --- minimal discovery ---
98        let client = Client::builder()
99            .user_agent("foxy/oidc")
100            .build()?;
101        #[derive(Deserialize)]
102        struct Discovery { jwks_uri: String }
103        let meta: Discovery = client
104            .get(&cfg.issuer_uri)
105            .send()
106            .await?
107            .error_for_status()?
108            .json()
109            .await?;
110
111        // --- compile bypass rules ---
112        let mut rules = Vec::with_capacity(cfg.bypass.len());
113        for raw in cfg.bypass {
114            let mut builder = GlobSetBuilder::new();
115            builder.add(Glob::new(&raw.path)?);
116            rules.push(RouteRule {
117                methods: raw.methods.iter().map(|m| m.to_ascii_uppercase()).collect(),
118                paths: builder.build()?,
119            });
120        }
121
122        Ok(Self {
123            issuer: cfg
124                .issuer_uri
125                .trim_end_matches("/.well-known/openid-configuration")
126                .to_owned(),
127            aud: cfg.aud,
128            shared_secret: cfg.shared_secret,
129            jwks_uri: meta.jwks_uri,
130            jwks: Arc::new(RwLock::new(None)),
131            last_refresh: Arc::new(RwLock::new(
132                tokio::time::Instant::now() - JWKS_REFRESH,
133            )),
134            http: client,
135            rules,
136        })
137    }
138
139    /* ---------- helpers -------------------------------------------------- */
140
141    async fn refresh_jwks(&self) -> Result<(), ProxyError> {
142        let now = tokio::time::Instant::now();
143        if now.duration_since(*self.last_refresh.read().await) < JWKS_REFRESH {
144            return Ok(());
145        }
146        let set = self
147            .http
148            .get(&self.jwks_uri)
149            .send()
150            .await?
151            .error_for_status()?
152            .json::<JwkSet>()
153            .await?;
154        *self.jwks.write().await = Some(set);
155        *self.last_refresh.write().await = now;
156        Ok(())
157    }
158
159    fn validate_std_claims(&self, claims: &serde_json::Value) -> Result<(), ProxyError> {
160        if claims["iss"] != self.issuer {
161            return Err(ProxyError::SecurityError("bad issuer".into()));
162        }
163        if let Some(ref aud) = self.aud {
164            let ok = claims["aud"]
165                .as_str()
166                .map(|a| a == aud)
167                .unwrap_or(false);
168            if !ok {
169                return Err(ProxyError::SecurityError("bad audience".into()));
170            }
171        }
172        Ok(())
173    }
174
175    #[inline]
176    fn is_bypassed(&self, method: &str, path: &str) -> bool {
177        self.rules.iter().any(|r| r.matches(method, path))
178    }
179}
180
181#[async_trait]
182impl SecurityProvider for OidcProvider {
183    fn name(&self) -> &str { "OidcProvider" }
184
185    fn stage(&self) -> SecurityStage { SecurityStage::Pre }
186
187    async fn pre(&self, mut req: ProxyRequest) -> Result<ProxyRequest, ProxyError> {
188        // 0) Bypass?
189        if self.is_bypassed(&req.method.to_string(), &req.path) {
190            return Ok(req);
191        }
192
193        // 1) Extract bearer token
194        let auth = req
195            .headers
196            .get("authorization")
197            .and_then(|v| v.to_str().ok())
198            .ok_or_else(|| ProxyError::SecurityError("missing Authorization".into()))?
199            .to_ascii_lowercase();
200        if !auth.starts_with(BEARER) {
201            return Err(ProxyError::SecurityError("unsupported auth scheme".into()));
202        }
203        let token = auth.trim_start_matches(BEARER).trim();
204
205        // 2) Decode header / alg / kid
206        let header = decode_header(token)?;
207        let allowed_algs = [
208            Algorithm::RS256,
209            Algorithm::RS384,
210            Algorithm::RS512,
211            Algorithm::PS256,
212            Algorithm::PS384,
213            Algorithm::PS512,
214            Algorithm::ES256,
215            Algorithm::ES384,
216            Algorithm::EdDSA,
217            Algorithm::HS256,
218            Algorithm::HS384,
219            Algorithm::HS512,
220        ];
221        if !allowed_algs.contains(&header.alg) {
222            return Err(ProxyError::SecurityError("alg not allowed".into()));
223        }
224
225        // 3) Build decoding key
226        let decoding_key = match header.alg {
227            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
228                let secret = self.shared_secret.as_deref().ok_or_else(|| {
229                    ProxyError::SecurityError("shared-secret not configured".into())
230                })?;
231                DecodingKey::from_secret(secret.as_bytes())
232            }
233            _ => {
234                self.refresh_jwks().await?;
235                let kid = header
236                    .kid
237                    .ok_or_else(|| ProxyError::SecurityError("missing kid".into()))?;
238                
239                let jwks_guard = self.jwks.read().await;
240                let set = jwks_guard
241                    .as_ref()
242                    .ok_or_else(|| ProxyError::SecurityError("no JWKS cache".into()))?;
243
244                let jwk = set
245                    .find(&kid)
246                    .ok_or_else(|| ProxyError::SecurityError("unknown kid".into()))?;
247                
248                jwk_to_decoding_key(jwk)?
249            }
250        };
251
252        // 4) Validate signature & std claims
253        let mut validation = Validation::new(header.alg);
254        validation.set_required_spec_claims(&["exp", "iss"]);
255
256        let data = decode::<serde_json::Value>(token, &decoding_key, &validation)?;
257        self.validate_std_claims(&data.claims)?;
258
259        // Expose claims downstream via request.context
260        {
261            let mut ctx = req.context.write().await;
262            ctx.attributes.insert(CLAIMS_ATTRIBUTE.into(), data.claims);
263        }
264
265        Ok(req)
266    }
267}