Skip to main content

wasm_oidc_plugin/
config.rs

1// aes_gcm
2use aes_gcm::{Aes256Gcm, KeyInit};
3use jwt_simple::reexports::anyhow::{self, ensure};
4
5// core
6use core::fmt;
7
8// sec
9use sec::Secret;
10
11// std
12use std::fmt::Debug;
13
14// serde
15use serde::{Deserialize, Deserializer};
16
17// serde_regex
18use regex::Regex;
19
20// url
21use url::Url;
22
23/// Struct that holds the configuration for the plugin. It is loaded from the config file `envoy.yaml`
24#[derive(Clone, Debug, Deserialize)]
25pub struct V2PluginConfiguration {
26    /// Exclude hosts. Example: localhost:10000
27    #[serde(with = "serde_regex")]
28    pub exclude_hosts: Vec<Regex>,
29    /// Exclude paths. Example: /health
30    #[serde(with = "serde_regex")]
31    pub exclude_paths: Vec<Regex>,
32    /// Exclude urls. Example: localhost:10000/health
33    #[serde(with = "serde_regex")]
34    pub exclude_urls: Vec<Regex>,
35
36    // Header forwarding settings
37    /// The header name that will be used for the access token.
38    /// If the header name is empty, the access token will not be forwarded
39    pub access_token_header_name: Option<String>,
40    /// Prefix for the access token header.
41    /// If the prefix is empty, the access token will be forwarded without a prefix
42    pub access_token_header_prefix: Option<String>,
43
44    /// The header name that will be used for the id token.
45    /// If the header name is empty, the id token will not be forwarded
46    pub id_token_header_name: Option<String>,
47    /// Prefix for the id token header.
48    /// If the prefix is empty, the id token will be forwarded without a prefix
49    pub id_token_header_prefix: Option<String>,
50
51    // Cookie settings
52    /// The cookie name that will be used for the session cookie
53    pub cookie_name: String,
54    /// The URL to logout the user
55    pub logout_path: String,
56    /// Filter out the cookies created and controlled by the plugin
57    /// If the value is true, the cookies will be filtered out
58    pub filter_plugin_cookies: bool,
59    /// The cookie duration in seconds
60    pub cookie_duration_in_s: u64,
61    /// Option to skip Token Validation
62    pub token_validation: bool,
63    /// AES Key
64    #[serde(deserialize_with = "deserialize_aes_key")]
65    pub aes_key: Secret<Aes256Gcm>,
66
67    // OpenID Connect Configuration
68    /// Reload interval in hours
69    pub reload_interval_in_h: u64,
70    /// The interval in milliseconds that the plugin will wait for the discovery endpoint to respond or send a new request.
71    pub ticking_interval_in_ms: u64,
72    /// A list of OpenID Connect configurations that will be used for the filter
73    pub open_id_configs: Vec<OpenIdConfig>,
74}
75
76impl V2PluginConfiguration {
77    pub fn parse(config_bytes: &[u8]) -> anyhow::Result<(Self, bool)> {
78        // Try to parse as new format first
79        if let Ok(config) = serde_yaml::from_slice::<Self>(config_bytes) {
80            config.validate()?;
81            return Ok((config, false));
82        }
83
84        // If new format fails, try legacy format
85        match serde_yaml::from_slice::<V1PluginConfiguration>(config_bytes) {
86            Ok(legacy_config) => {
87                let config = legacy_config.to_new_format()?;
88                config.validate()?;
89                Ok((config, true))
90            }
91            Err(e) => {
92                anyhow::bail!(
93                    "Failed to parse configuration in both new and legacy formats. Last error: {}",
94                    e
95                )
96            }
97        }
98    }
99
100    /// Evaluate the plugin configuration and check if the values are valid.
101    /// Type checking is done by serde, so we only need to check the values.
102    fn validate(&self) -> anyhow::Result<()> {
103        ensure!(self.reload_interval_in_h > 0, "`reload_interval` is 0");
104        ensure!(self.ticking_interval_in_ms > 0, "`ticking_interval` is 0");
105        ensure!(
106            self.cookie_name.len() <= 32,
107            "`cookie_name` is too long, max 32"
108        );
109
110        let cookies_name_regex = Regex::new(r"^[\w\d-]+$").unwrap();
111        ensure!(cookies_name_regex.is_match(&self.cookie_name), "`cookie_name` is empty or not valid meaning that it contains invalid characters like ;, =, :, /, space");
112
113        ensure!(!self.logout_path.is_empty(), "`logout_path` is empty");
114        ensure!(
115            self.logout_path.starts_with('/'),
116            "`logout_path` does not start with a `/`"
117        );
118        ensure!(self.cookie_duration_in_s > 0, "`cookie_duration_in_s` is 0");
119
120        for provider in &self.open_id_configs {
121            ensure!(!provider.authority.is_empty(), "`authority` is empty");
122            ensure!(!provider.client_id.is_empty(), "`client_id` is empty");
123            ensure!(!provider.scope.is_empty(), "`scope` is empty");
124            ensure!(
125                !provider.client_secret.reveal().is_empty(),
126                "`client_secret` is empty"
127            );
128            ensure!(!provider.audience.is_empty(), "audience is empty");
129        }
130
131        Ok(())
132    }
133}
134
135/// Struct that holds the configuration for the OpenID Connect provider
136#[derive(Clone, Debug, Deserialize)]
137pub struct OpenIdConfig {
138    // Metadata
139    /// Name of the OpenID Connect Provider
140    pub name: String,
141    /// Image of the OpenID Connect Provider, will be shown in the screen where the user can select the provider
142    pub image: Url,
143
144    // Everything relevant for the Code Flow
145    /// Config endpoint for the plugin.
146    pub config_endpoint: Url,
147    /// Upstream Cluster name
148    pub upstream_cluster: String,
149    /// The authority that will be used for the dispatch calls
150    pub authority: String,
151    /// The redirect uri that the authorization endpoint will redirect to and provide the code
152    pub redirect_uri: Url,
153    /// The client id
154    pub client_id: String,
155    /// The scope
156    pub scope: String,
157    /// The claims
158    pub claims: serde_json::Map<String, serde_json::Value>,
159
160    // Everything relevant for the Token Exchange Flow
161    /// The client secret
162    pub client_secret: Secret<String>,
163    /// The audience. Sometimes its the same as the client id
164    pub audience: String,
165}
166
167/// Default value for logout_path in legacy configs
168fn default_logout_path() -> Option<String> {
169    Some("/logout".to_string())
170}
171
172/// Legacy configuration structure for backwards compatibility.
173/// This represents the old flat configuration format that only supported a single provider.
174#[derive(Clone, Debug, Deserialize)]
175struct V1PluginConfiguration {
176    // OpenID Connect Configuration (flat structure - single provider only)
177    /// Config endpoint for the plugin.
178    pub config_endpoint: Url,
179    /// Reload interval in hours
180    pub reload_interval_in_h: u64,
181
182    /// Exclude hosts. Example: localhost:10000
183    #[serde(with = "serde_regex")]
184    pub exclude_hosts: Vec<Regex>,
185    /// Exclude paths. Example: /health
186    #[serde(with = "serde_regex")]
187    pub exclude_paths: Vec<Regex>,
188    /// Exclude urls. Example: localhost:10000/health
189    #[serde(with = "serde_regex")]
190    pub exclude_urls: Vec<Regex>,
191
192    // Header forwarding settings
193    /// The header name that will be used for the access token.
194    pub access_token_header_name: Option<String>,
195    /// Prefix for the access token header.
196    pub access_token_header_prefix: Option<String>,
197    /// The header name that will be used for the id token.
198    pub id_token_header_name: Option<String>,
199    /// Prefix for the id token header.
200    pub id_token_header_prefix: Option<String>,
201
202    // Cookie settings
203    /// The cookie name that will be used for the session cookie
204    pub cookie_name: String,
205    /// The URL to logout the user (optional in legacy format, defaults to "/logout")
206    #[serde(default = "default_logout_path")]
207    pub logout_path: Option<String>,
208    /// Filter out the cookies created and controlled by the plugin
209    pub filter_plugin_cookies: bool,
210    /// The cookie duration in seconds
211    pub cookie_duration: u64,
212    /// Option to skip Token Validation
213    pub token_validation: bool,
214    /// AES Key
215    #[serde(deserialize_with = "deserialize_aes_key")]
216    pub aes_key: Secret<Aes256Gcm>,
217
218    // Single provider fields (legacy)
219    /// The authority that will be used for the dispatch calls
220    pub authority: String,
221    /// The redirect uri that the authorization endpoint will redirect to and provide the code
222    pub redirect_uri: Url,
223    /// The client id
224    pub client_id: String,
225    /// The scope
226    pub scope: String,
227    /// The claims (as a JSON string in the legacy format)
228    pub claims: String,
229    /// The client secret
230    pub client_secret: Secret<String>,
231    /// The audience
232    pub audience: String,
233}
234
235impl V1PluginConfiguration {
236    /// Convert legacy configuration to new multi-provider format
237    #[allow(clippy::wrong_self_convention)]
238    fn to_new_format(self) -> anyhow::Result<V2PluginConfiguration> {
239        // Parse the claims JSON string
240        let claims: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&self.claims)
241            .map_err(|e| anyhow::anyhow!("Failed to parse claims JSON in legacy config: {}", e))?;
242
243        // Create a default placeholder image URL
244        let default_image = Url::parse(
245            "https://developers.elementor.com/docs/assets/img/elementor-placeholder-image.png",
246        )
247        .expect("Default placeholder URL should be valid");
248
249        // Create a single OpenIdConfig from the legacy flat structure
250        let open_id_config = OpenIdConfig {
251            name: "legacy-provider".to_string(),
252            image: default_image,
253            config_endpoint: self.config_endpoint,
254            upstream_cluster: "oidc".to_string(),
255            authority: self.authority,
256            redirect_uri: self.redirect_uri,
257            client_id: self.client_id,
258            scope: self.scope,
259            claims,
260            client_secret: self.client_secret,
261            audience: self.audience,
262        };
263
264        // Construct the new PluginConfiguration with the legacy provider as a single entry
265        Ok(V2PluginConfiguration {
266            open_id_configs: vec![open_id_config],
267            reload_interval_in_h: self.reload_interval_in_h,
268            ticking_interval_in_ms: 500, // Default value for legacy configs
269            exclude_hosts: self.exclude_hosts,
270            exclude_paths: self.exclude_paths,
271            exclude_urls: self.exclude_urls,
272            access_token_header_name: self.access_token_header_name,
273            access_token_header_prefix: self.access_token_header_prefix,
274            id_token_header_name: self.id_token_header_name,
275            id_token_header_prefix: self.id_token_header_prefix,
276            cookie_name: self.cookie_name,
277            logout_path: self.logout_path.unwrap_or_else(|| "/logout".to_string()),
278            filter_plugin_cookies: self.filter_plugin_cookies,
279            cookie_duration_in_s: self.cookie_duration,
280            token_validation: self.token_validation,
281            aes_key: self.aes_key,
282        })
283    }
284}
285
286/// Deserialize a base64 encoded 32 byte AES key
287fn deserialize_aes_key<'de, D>(deserializer: D) -> Result<Secret<Aes256Gcm>, D::Error>
288where
289    D: Deserializer<'de>,
290{
291    use base64::{engine::general_purpose::STANDARD as base64engine, Engine as _};
292    use serde::de::{Error, Visitor};
293
294    struct AesKeyVisitor;
295
296    impl<'de> Visitor<'de> for AesKeyVisitor {
297        type Value = Secret<Aes256Gcm>;
298
299        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
300            formatter.write_str("a base64 string encoding a 32 byte AES key")
301        }
302
303        fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
304        where
305            E: Error,
306        {
307            let aes_key = base64engine.decode(s).map_err(Error::custom)?;
308            let cipher = Aes256Gcm::new_from_slice(&aes_key).map_err(|e| {
309                Error::custom(format!("{e}, got {} bytes, expected 32", aes_key.len()))
310            })?;
311
312            Ok(Secret::new(cipher))
313        }
314    }
315
316    // using a visitor here instead of just <&str>::deserialize
317    // makes sure that any error message contains the field name
318    deserializer.deserialize_str(AesKeyVisitor)
319}