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}