Skip to main content

jwks_cache/http/
semantics.rs

1//! HTTP cache semantics integration helpers.
2
3// crates.io
4use http::{Method, Request, Response, Uri};
5use http_cache_semantics::{AfterResponse, CachePolicy};
6// self
7use crate::{_prelude::*, http::client::HttpExchange, registry::IdentityProviderRegistration};
8
9/// Freshness evaluation derived from HTTP headers and registry policy.
10#[derive(Clone, Debug)]
11pub struct Freshness {
12	/// Effective time-to-live allowed for the JWKS payload.
13	/// Clamped TTL in seconds, derived from HTTP Cache-Control and registry bounds.
14	pub ttl: Duration,
15	/// HTTP cache policy describing future request handling.
16	pub policy: CachePolicy,
17}
18
19/// Result of applying conditional revalidation.
20#[derive(Debug)]
21pub struct Revalidation {
22	/// Freshness information resulting from the revalidation exchange.
23	pub freshness: Freshness,
24	/// Response synthesized from the revalidation outcome.
25	pub response: Response<()>,
26	/// Flag indicating the upstream representation changed.
27	pub modified: bool,
28}
29
30/// Build a baseline HTTP request for the provider JWKS endpoint.
31pub fn base_request(registration: &IdentityProviderRegistration) -> Result<Request<()>> {
32	let uri = parse_uri(registration)?;
33
34	Request::builder()
35		.method(Method::GET)
36		.uri(uri)
37		.header("accept", "application/json")
38		.body(())
39		.map_err(Error::from)
40}
41
42/// Evaluate HTTP cache semantics to determine TTL for the fetched JWKS document.
43pub fn evaluate_freshness(
44	registration: &IdentityProviderRegistration,
45	exchange: &HttpExchange,
46) -> Result<Freshness> {
47	let policy = CachePolicy::new(&exchange.request, &exchange.response);
48	let storable = policy.is_storable();
49	let ttl = if storable {
50		clamp_ttl(
51			policy.time_to_live(SystemTime::now()),
52			registration.min_ttl,
53			registration.max_ttl,
54		)
55	} else {
56		registration.min_ttl
57	};
58
59	tracing::debug!(ttl=?ttl, storable, "evaluated freshness");
60
61	Ok(Freshness { ttl, policy })
62}
63
64/// Evaluate cache semantics for a conditional revalidation attempt.
65pub fn evaluate_revalidation(
66	registration: &IdentityProviderRegistration,
67	policy: &CachePolicy,
68	request: &Request<()>,
69	response: &Response<()>,
70) -> Result<Revalidation> {
71	let now = SystemTime::now();
72	let outcome = policy.after_response(request, response, now);
73	let (policy, parts, modified) = match outcome {
74		AfterResponse::NotModified(policy, parts) => (policy, parts, false),
75		AfterResponse::Modified(policy, parts) => (policy, parts, true),
76	};
77	let response = Response::from_parts(parts, ());
78	let ttl = clamp_ttl(policy.time_to_live(now), registration.min_ttl, registration.max_ttl);
79
80	Ok(Revalidation { freshness: Freshness { ttl, policy }, response, modified })
81}
82
83fn parse_uri(registration: &IdentityProviderRegistration) -> Result<Uri> {
84	registration.jwks_url.as_str().parse::<Uri>().map_err(|err| Error::Validation {
85		field: "jwks_url",
86		reason: format!("Failed to convert URL to http::Uri: {err}."),
87	})
88}
89
90fn clamp_ttl(ttl: Duration, min: Duration, max: Duration) -> Duration {
91	if ttl < min {
92		min
93	} else if ttl > max {
94		max
95	} else {
96		ttl
97	}
98}
99
100#[cfg(test)]
101mod tests {
102	// crates.io
103	use http::{
104		StatusCode,
105		header::{CACHE_CONTROL, ETAG},
106	};
107	use http_cache_semantics::BeforeRequest;
108	// self
109	use super::*;
110
111	fn make_registration() -> IdentityProviderRegistration {
112		IdentityProviderRegistration::new(
113			"tenant",
114			"provider",
115			"https://example.com/.well-known/jwks.json",
116		)
117		.expect("registration")
118	}
119
120	#[test]
121	fn clamps_ttl_to_registration_bounds() {
122		let mut registration = make_registration();
123
124		registration.min_ttl = Duration::from_secs(30);
125		registration.max_ttl = Duration::from_secs(60);
126
127		let request = base_request(&registration).expect("request");
128		let response = Response::builder()
129			.status(StatusCode::OK)
130			.header(CACHE_CONTROL, "max-age=5")
131			.body(())
132			.expect("response");
133		let exchange = HttpExchange::new(request, response, Duration::from_millis(12));
134		let freshness = evaluate_freshness(&registration, &exchange).expect("freshness");
135
136		assert_eq!(freshness.ttl, Duration::from_secs(30));
137	}
138
139	#[test]
140	fn adds_etag_to_conditional_revalidation_headers() {
141		let mut registration = make_registration();
142
143		registration.require_https = false;
144		registration.min_ttl = Duration::from_secs(1);
145		registration.max_ttl = Duration::from_secs(10);
146
147		let request = base_request(&registration).expect("request");
148		let response = Response::builder()
149			.status(StatusCode::OK)
150			.header(CACHE_CONTROL, "max-age=1")
151			.header(ETAG, "\"jwks-tag\"")
152			.body(())
153			.expect("response");
154		let exchange = HttpExchange::new(request.clone(), response, Duration::from_millis(8));
155		let freshness = evaluate_freshness(&registration, &exchange).expect("freshness");
156		let request = base_request(&registration).expect("request");
157		let decision =
158			freshness.policy.before_request(&request, SystemTime::now() + Duration::from_secs(5));
159
160		match decision {
161			BeforeRequest::Stale { request, .. } => {
162				let if_none_match = request.headers.get("if-none-match");
163
164				assert_eq!(
165					if_none_match.and_then(|value| value.to_str().ok()),
166					Some("\"jwks-tag\"")
167				);
168			},
169			BeforeRequest::Fresh(_) => {
170				panic!("expected stale decision triggering conditional headers")
171			},
172		}
173	}
174}