Skip to main content

wasm_oidc_plugin/
discovery.rs

1// arc
2use std::sync::{Arc, Mutex};
3
4// duration
5use std::time::Duration;
6
7// log
8use log::{debug, error, info, warn};
9
10// proxy-wasm
11use proxy_wasm::{hostcalls, traits::*, types::*};
12
13// std
14use std::fmt;
15
16// url
17use url::Url;
18
19// crate
20use crate::auth::OidcHttpContext;
21use crate::config::{OpenIdConfig, V2PluginConfiguration};
22use crate::pause::PauseRequests;
23use crate::responses::{JWKsResponse, OpenIdDiscoveryResponse, SigningKey};
24
25/// This is the main context which loads and parses the plugin configuration, handles the discovery of all
26/// Open ID Providers and creates the HTTP Contexts.
27pub struct Root {
28    /// Plugin config loaded from the envoy configuration
29    pub plugin_config: Option<Arc<V2PluginConfiguration>>,
30    /// A set of Open ID Resolvers which are used to load the configuration from the discovery endpoint
31    pub open_id_resolvers: Vec<OpenIdResolver>,
32    /// A set of Open ID Providers which are used to store the configuration from the discovery endpoint
33    pub open_id_providers: Vec<OpenIdProvider>,
34    /// Queue of waiting requests which are waiting for the configuration to be loaded
35    pub waiting: Mutex<Vec<u32>>,
36    /// Flag to determine if the discovery is active
37    pub discovery_active: bool,
38}
39
40#[derive(Debug)]
41/// A resolver handles the loading of the configuration from the open id discovery endpoint and the jwks endpoint.
42pub struct OpenIdResolver {
43    /// The state of the resolver
44    pub state: OpenIdResolverState,
45    /// The configuration from the plugin configuration
46    pub open_id_config: OpenIdConfig,
47    /// token_ids of the HttpCalls to verify the call is correct and to determine which response comes in
48    token_ids: Vec<u32>,
49}
50
51/// The state of the resolver is an enum which has the following variants:
52/// - LoadingConfig: The plugin configuration is being loaded
53/// - LoadingJwks: The jwks configuration is being loaded
54/// - Ready: The plugin is ready
55#[allow(clippy::large_enum_variant)]
56#[derive(Debug)]
57pub enum OpenIdResolverState {
58    /// The root context is loading the configuration from the open id discovery endpoint
59    LoadingConfig,
60    /// The root context is loading the jwks configuration using the open id configuration
61    LoadingJwks {
62        /// response from the config endpoint
63        open_id_response: Arc<OpenIdDiscoveryResponse>,
64    },
65    /// The root context is ready
66    Ready,
67}
68
69impl fmt::Display for OpenIdResolverState {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            OpenIdResolverState::LoadingConfig => write!(f, "LoadingConfig"),
73            OpenIdResolverState::LoadingJwks { .. } => write!(f, "LoadingJwks"),
74            OpenIdResolverState::Ready => write!(f, "Ready"),
75        }
76    }
77}
78
79/// The OpenIdProvider struct holds all information about the Open ID Provider that is needed for the
80/// plugin to work. This includes the Open ID Configuration, the URLs of the endpoints and the public keys
81/// that are used for the validation of the ID Token.
82#[derive(Clone, Debug)]
83pub struct OpenIdProvider {
84    pub open_id_config: OpenIdConfig,
85    /// The URL of the authorization endpoint
86    pub auth_endpoint: Url,
87    /// The URL of the token endpoint
88    pub token_endpoint: Url,
89    /// The URL of the end session endpoint
90    pub end_session_endpoint: Option<Url>,
91    /// The issuer that will be used for the token request
92    pub issuer: String,
93    /// The public keys that will be used for the validation of the ID Token
94    pub public_keys: Vec<SigningKey>,
95}
96
97/// The root context creates new HTTP Contexts and is responsible for loading the plugin configuration, as
98/// well as the discovery of the Open ID Providers.
99/// The first step after startup is the loading of the plugin configuration. This is done in the `on_configure`
100/// function. The plugin configuration is loaded from the plugin configuration and parsed into the
101/// `PluginConfiguration` struct. The configuration is then evaluated and checked if the values are valid.
102/// If the configuration is valid, the plugin configuration is stored in the root context and the next state
103/// is set. The next state is to load the configuration from the Open ID Providers. This is done by creating
104/// a new `OpenIdResolver` for each Open ID Provider in the plugin configuration. The state of the resolver
105/// is set to `LoadingConfig` and the configuration is loaded from the Open ID Configuration endpoint. The
106/// response is handled in the `on_http_call_response` function. If the response is successful, the state is
107/// set to `LoadingJwks` and the jwks endpoint is called. The response is handled in the `on_http_call_response`
108/// function. If the response is successful, the state is set to `Ready` and the Open ID Provider is stored in
109/// the root context. If all Open ID Providers are in the `Ready` state, the plugin is ready and the waiting
110/// requests are resumed.
111impl RootContext for Root {
112    /// Called when proxy is being configured.
113    /// This is where the plugin configuration is loaded and the next state is set.
114    fn on_configure(&mut self, _plugin_configuration_size: usize) -> bool {
115        info!("plugin is configuring");
116
117        // Load the configuration from the plugin configuration.
118        if let Some(config_bytes) = self.get_plugin_configuration() {
119            debug!("got plugin configuration");
120
121            let (plugin_config, is_legacy) = match V2PluginConfiguration::parse(&config_bytes) {
122                Ok(result) => result,
123                Err(e) => {
124                    error!("plugin configuration is invalid: {e}");
125                    return false;
126                }
127            };
128
129            // Log deprecation warning if legacy format was used
130            if is_legacy {
131                warn!(
132                    "legacy single-provider config is deprecated; migrate with: \
133                     python3 scripts/migrate-config.py <plugin-config.yaml> -o migrated.yaml \
134                     (see README.md#migrating-from-the-legacy-single-provider-config)"
135                );
136            }
137
138            self.plugin_config = Some(Arc::new(plugin_config.clone()));
139
140            // Create a new resolver for each open id provider in the plugin configuration.
141            let mut resolvers = vec![];
142            for open_id_config in plugin_config.open_id_configs.clone() {
143                info!(
144                    "creating resolver for open id config: {:?}",
145                    open_id_config.name
146                );
147
148                // Advance to the next state and store the plugin configuration.
149                let open_id_resolver = OpenIdResolver {
150                    state: OpenIdResolverState::LoadingConfig,
151                    open_id_config,
152                    token_ids: vec![],
153                };
154                resolvers.push(open_id_resolver);
155            }
156            self.open_id_resolvers = resolvers;
157
158            // Tick immediately to load the configuration.
159            // See `on_tick` for more information.
160            self.set_tick_period(Duration::from_millis(1));
161
162            true
163        } else {
164            error!("no plugin configuration");
165            false
166        }
167    }
168
169    /// Creates the http context with the information from the open_id_providers and the plugin_configuration.
170    /// This is called whenever a new http context is created by the proxy.
171    /// When the plugin is not yet ready, the http context is created in `PauseRequests` state and the
172    /// context id is added to the waiting queue to be processed later.
173    fn create_http_context(&self, context_id: u32) -> Option<Box<dyn HttpContext>> {
174        // Check if all open id providers are ready
175        match self.discovery_active {
176            // If the plugin is ready, create the http context `OidcHttpContext` with the root context information.
177            false => {
178                debug!("creating http context with root context information");
179
180                // Return the http context.
181                Some(Box::new(OidcHttpContext {
182                    open_id_providers: self.open_id_providers.clone(),
183                    plugin_config: self.plugin_config.clone()?,
184                    token_id: None,
185                    request_id: "no x-request-id header".to_owned(),
186                }))
187            }
188
189            // If the plugin is not ready, return the http context in `PauseRequests` state.
190            _ => {
191                warn!("root context is not ready yet, queueing http context.");
192
193                // Add the context id to the waiting queue.
194                self.waiting.lock().unwrap().push(context_id);
195
196                // Return the http context in `Unconfigured` state.
197                Some(Box::new(PauseRequests {
198                    original_path: None,
199                }))
200            }
201        }
202    }
203
204    /// The root context is ticking every the configured interval (x) as long as the configuration is not loaded yet.
205    ///
206    /// On every tick, the plugin is checking if the discovery is active. If the discovery is not active,
207    /// the plugin is starting the discovery (as it has been waiting for `reload_interval_in_h` * 3600).
208    /// The discovery is started by setting the discovery active to true and setting the state of all resolvers
209    /// to `LoadingConfig`. The ticking period is set to x ms to not overload the openid configuration endpoint (x is
210    /// the configured interval).
211    ///
212    /// If the discovery is active, the plugin is checking if all resolvers are in `Ready` state. If all resolvers
213    /// are in `Ready` state, the plugin is resuming all requests that were sent during the loading phase. The
214    /// discovery is switched to false and the ticking period is set to the configured interval.
215    ///
216    /// If the discovery is active and not all resolvers are in `Ready` state, the plugin is making a call to the
217    /// openid configuration endpoint or the jwks endpoint depending on the state of the resolver.
218    fn on_tick(&mut self) {
219        debug!("tick");
220
221        let Some(plugin_config) = self.plugin_config.as_ref() else {
222            warn!("plugin configuration not available during tick");
223            return;
224        };
225        let ticking_interval_in_ms = plugin_config.ticking_interval_in_ms;
226        let reload_interval_in_h = plugin_config.reload_interval_in_h;
227
228        // Discovery is not active, start discovery
229        if !self.discovery_active {
230            info!("discovery is not active, starting discovery");
231
232            // Set discovery to active and set the state of all resolvers to `LoadingConfig`.
233            self.discovery_active = true;
234            for resolver in self.open_id_resolvers.iter_mut() {
235                resolver.state = OpenIdResolverState::LoadingConfig;
236            }
237            // Tick every x ms to not overload the openid configuration endpoint. x is the configured interval.
238            self.set_tick_period(Duration::from_millis(ticking_interval_in_ms));
239        }
240
241        // If all providers are in `Ready` state, any request that was sent during the loading phase,
242        // is now resumed. Also, the discovery is switched and the ticking period if set to the
243        // configured interval.
244        let all_resolvers_done = self
245            .open_id_resolvers
246            .iter_mut()
247            .all(|r| matches!(r.state, OpenIdResolverState::Ready));
248
249        if self.discovery_active && all_resolvers_done {
250            info!(
251                "discovery is done, resuming {} waiting requests",
252                self.waiting.lock().unwrap().len()
253            );
254
255            // Resume all requests that were sent during the loading phase. See `PauseRequest` for more.
256            for context_id in self.waiting.lock().unwrap().drain(..) {
257                debug!("resuming queued request with id {}", context_id);
258                hostcalls::set_effective_context(context_id).unwrap_or_else(|e| {
259                        warn!("error setting effective context, most likely the tab was closed already: {:?}", e);
260                    });
261                hostcalls::resume_http_request().unwrap_or_else(|e| {
262                    warn!(
263                        "error resuming http request, most likely the tab was closed already: {:?}",
264                        e
265                    );
266                });
267            }
268
269            // Switch discovery to inactive and set the ticking period to the configured interval.
270            self.discovery_active = false;
271            self.set_tick_period(Duration::from_secs(reload_interval_in_h * 3600));
272        }
273
274        // Make call to openid configuration endpoint for all providers whose state is not ready.
275        for resolver in self.open_id_resolvers.iter_mut() {
276            match &resolver.state {
277                OpenIdResolverState::LoadingConfig => {
278                    // Make call to openid configuration endpoint and load configuration
279                    // The response is handled in `on_http_call_response`.
280                    match hostcalls::dispatch_http_call(
281                        &resolver.open_id_config.upstream_cluster,
282                        vec![
283                            (":method", "GET"),
284                            (":path", resolver.open_id_config.config_endpoint.path()),
285                            (":authority", resolver.open_id_config.authority.as_str()),
286                        ],
287                        None,
288                        vec![],
289                        Duration::from_secs(5),
290                    ) {
291                        Err(e) => warn!("error dispatching oidc call: {:?}", e),
292                        Ok(id) => {
293                            resolver.token_ids.push(id);
294                            debug!(
295                                "dispatched openid config call to {}, count of unanswered request: {}",
296                                resolver.open_id_config.config_endpoint,
297                                resolver.token_ids.len()
298                            );
299                        }
300                    }
301                }
302
303                // Make call to jwks endpoint for all providers whose state is not ready.
304                // The response is handled in `on_http_call_response`.
305                OpenIdResolverState::LoadingJwks { open_id_response } => {
306                    match hostcalls::dispatch_http_call(
307                        &resolver.open_id_config.upstream_cluster,
308                        vec![
309                            (":method", "GET"),
310                            (":path", open_id_response.jwks_uri.path()),
311                            (":authority", open_id_response.jwks_uri.authority()),
312                        ],
313                        None,
314                        vec![],
315                        Duration::from_secs(5),
316                    ) {
317                        Err(e) => warn!("error dispatching jwks call: {:?}", e),
318                        Ok(id) => {
319                            resolver.token_ids.push(id);
320                            debug!(
321                                "dispatched jwks call to {}, count of unanswered request: {}",
322                                open_id_response.jwks_uri,
323                                resolver.token_ids.len()
324                            );
325                        }
326                    }
327                }
328                OpenIdResolverState::Ready => {
329                    // Clear all token ids as the resolver is ready
330                    resolver.token_ids.clear();
331                }
332            }
333        }
334    }
335    /// This is one of those functions that need to be there for some reason but we are
336    /// not sure why. It just doesn't work without it.
337    fn get_type(&self) -> Option<proxy_wasm::types::ContextType> {
338        Some(ContextType::HttpContext)
339    }
340}
341
342/// The context processes all responses from the open id config endpoints and jwks endpoints.
343impl Context for Root {
344    /// Called when the response from any http call (sent from root context) is received.
345    fn on_http_call_response(
346        &mut self,
347        token_id: u32,
348        _num_headers: usize,
349        _body_size: usize,
350        _num_trailers: usize,
351    ) {
352        debug!("received http call response with token_id: {token_id}");
353        let body = self.get_http_call_response_body(0, _body_size);
354
355        // Find resolver to update based on toke_id
356        let binding = &mut self.open_id_resolvers;
357        let resolver_to_update = match binding
358            .iter_mut()
359            .find(|resolver| resolver.token_ids.contains(&token_id))
360        {
361            Some(resolver) => resolver,
362            None => {
363                debug!("no resolver found for token_id: {token_id}");
364                return;
365            }
366        };
367
368        debug!(
369            "token_id {} is for resolver/provider {} in state {}",
370            token_id, resolver_to_update.open_id_config.name, resolver_to_update.state
371        );
372
373        // Check for each state what to do with the response.
374        match &resolver_to_update.state {
375            // If the plugin is in Loading `LoadingConfig` state, the response is expected to be the
376            // openid configuration.
377            OpenIdResolverState::LoadingConfig => {
378                // Parse the response body as json.
379                let body = match body {
380                    Some(body) => body,
381                    None => {
382                        warn!("no body in openid config response");
383                        return;
384                    }
385                };
386
387                // Parse body using serde_json or fail
388                match serde_json::from_slice::<OpenIdDiscoveryResponse>(&body) {
389                    Err(e) => {
390                        warn!(
391                            "error parsing config response ({:?}): {:?}",
392                            String::from_utf8(body),
393                            e,
394                        );
395                    }
396                    Ok(open_id_response) => {
397                        debug!("parsed openid config response: {:#?}", open_id_response);
398
399                        // Set the state to `LoadingJwks`.
400                        resolver_to_update.state = OpenIdResolverState::LoadingJwks {
401                            open_id_response: Arc::new(open_id_response),
402                        };
403                        // And clear all token_ids
404                        resolver_to_update.token_ids.clear();
405                    }
406                }
407            }
408
409            // If the plugin is in `LoadingJwks` state, the jwks endpoint is expected.
410            OpenIdResolverState::LoadingJwks {
411                open_id_response, ..
412            } => {
413                // Parse body using serde_json or fail
414                let body = match body {
415                    Some(body) => body,
416                    None => {
417                        warn!("no body in jwks response");
418                        return;
419                    }
420                };
421
422                match serde_json::from_slice::<JWKsResponse>(&body) {
423                    Err(e) => {
424                        warn!("error parsing jwks body: {:?}", e);
425                    }
426                    Ok(jwks_response) => {
427                        debug!("parsed jwks body: {:#?}", jwks_response);
428
429                        // Check if keys are present
430                        if jwks_response.keys.is_empty() {
431                            warn!("no keys found in jwks response, retry in 1 minute");
432                            self.set_tick_period(Duration::from_secs(60));
433                            return;
434                        }
435
436                        // For all keys, create a signing key if possible
437                        let mut keys: Vec<SigningKey> = vec![];
438                        for key in jwks_response.keys {
439                            // Create the signing key from the JWK
440                            let signing_key = SigningKey::from(key);
441
442                            // Add the signing key to the list of keys
443                            keys.push(signing_key);
444                        }
445
446                        // Find OpenIdProvider to update or create a new one
447                        let provider = self.open_id_providers.iter_mut().find(|provider| {
448                            provider.open_id_config.name == resolver_to_update.open_id_config.name
449                        });
450
451                        let new_provider = OpenIdProvider {
452                            open_id_config: resolver_to_update.open_id_config.clone(),
453                            auth_endpoint: open_id_response.authorization_endpoint.clone(),
454                            token_endpoint: open_id_response.token_endpoint.clone(),
455                            end_session_endpoint: open_id_response.end_session_endpoint.clone(),
456                            issuer: open_id_response.issuer.clone(),
457                            public_keys: keys,
458                        };
459
460                        if let Some(p) = provider {
461                            *p = new_provider;
462                        } else {
463                            self.open_id_providers.push(new_provider);
464                        }
465
466                        // Set the state to `Ready` and clear all token_ids
467                        resolver_to_update.state = OpenIdResolverState::Ready {};
468                        resolver_to_update.token_ids.clear();
469                    }
470                }
471            }
472
473            // If the plugin is in `Ready` state, the response is ignored and the state is not changed.
474            OpenIdResolverState::Ready => {
475                warn!("ready state is not expected here");
476            }
477        }
478    }
479}