1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use anyhow::{bail, Context};
6use async_trait::async_trait;
7use axum::http::HeaderName;
8use jsonwebtoken::jwk::JwkSet;
9use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
10use serde_json::Value;
11use tokio::sync::RwLock;
12
13const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(60 * 60);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct GatewayPrincipal {
17 pub subject: Option<String>,
18 pub attributes: BTreeMap<String, String>,
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum GatewayAuthError {
23 #[error("gateway JWT header is missing")]
24 Missing,
25 #[error("gateway JWT is invalid: {0}")]
26 Invalid(String),
27 #[error("gateway signing keys are unavailable: {0}")]
28 KeysUnavailable(String),
29}
30
31#[async_trait]
32pub trait GatewayAuthenticator: Send + Sync {
33 fn header_name(&self) -> &HeaderName;
34
35 async fn authenticate(&self, token: &str) -> Result<GatewayPrincipal, GatewayAuthError>;
36}
37
38#[derive(Debug, Clone)]
39pub struct JwtGatewayConfig {
40 pub issuer: String,
41 pub audience: String,
42 pub jwks_url: String,
43 pub header_name: HeaderName,
44 pub required_claims: BTreeMap<String, String>,
45}
46
47impl JwtGatewayConfig {
48 pub fn new(
49 issuer: impl Into<String>,
50 audience: impl Into<String>,
51 jwks_url: impl Into<String>,
52 header_name: impl AsRef<str>,
53 required_claims: BTreeMap<String, String>,
54 ) -> anyhow::Result<Self> {
55 let issuer = issuer.into().trim_end_matches('/').to_owned();
56 let audience = audience.into();
57 let jwks_url = jwks_url.into();
58 if issuer.is_empty() || audience.trim().is_empty() || jwks_url.trim().is_empty() {
59 bail!("gateway JWT issuer, audience, and JWKS URL must not be empty");
60 }
61 validate_endpoint("issuer", &issuer)?;
62 validate_endpoint("JWKS URL", &jwks_url)?;
63 let header_name = HeaderName::from_bytes(header_name.as_ref().as_bytes())
64 .context("gateway JWT header name is invalid")?;
65 if required_claims.is_empty() {
66 bail!("at least one --access-jwt-required-claim is required");
67 }
68 if required_claims
69 .iter()
70 .any(|(name, value)| name.trim().is_empty() || value.trim().is_empty())
71 {
72 bail!("gateway JWT required claims must use non-empty NAME=VALUE pairs");
73 }
74 Ok(Self {
75 issuer,
76 audience,
77 jwks_url,
78 header_name,
79 required_claims,
80 })
81 }
82}
83
84fn validate_endpoint(label: &str, value: &str) -> anyhow::Result<()> {
85 let url =
86 reqwest::Url::parse(value).with_context(|| format!("gateway JWT {label} is invalid"))?;
87 let secure = url.scheme() == "https";
88 let local = url.scheme() == "http"
89 && url.host_str().is_some_and(|host| {
90 host == "localhost"
91 || host
92 .parse::<std::net::IpAddr>()
93 .is_ok_and(|ip| ip.is_loopback())
94 });
95 if !secure && !local {
96 bail!("gateway JWT {label} must use HTTPS (HTTP is allowed only on loopback)");
97 }
98 Ok(())
99}
100
101#[derive(Debug, Clone)]
102struct CachedKeys {
103 keys: JwkSet,
104 fetched_at: Instant,
105}
106
107#[derive(Debug)]
108pub struct JwtGatewayAuthenticator {
109 config: JwtGatewayConfig,
110 client: reqwest::Client,
111 cache: Arc<RwLock<Option<CachedKeys>>>,
112 cache_ttl: Duration,
113}
114
115impl JwtGatewayAuthenticator {
116 pub fn new(config: JwtGatewayConfig) -> anyhow::Result<Self> {
117 let client = reqwest::Client::builder()
118 .timeout(Duration::from_secs(10))
119 .redirect(reqwest::redirect::Policy::none())
120 .build()
121 .context("build gateway JWT JWKS client")?;
122 Ok(Self {
123 config,
124 client,
125 cache: Arc::new(RwLock::new(None)),
126 cache_ttl: DEFAULT_JWKS_TTL,
127 })
128 }
129
130 async fn keys(&self, force_refresh: bool) -> Result<JwkSet, GatewayAuthError> {
131 if !force_refresh {
132 let cache = self.cache.read().await;
133 if let Some(cached) = cache
134 .as_ref()
135 .filter(|cached| cached.fetched_at.elapsed() < self.cache_ttl)
136 {
137 return Ok(cached.keys.clone());
138 }
139 }
140 let keys = self
141 .client
142 .get(&self.config.jwks_url)
143 .send()
144 .await
145 .map_err(|error| GatewayAuthError::KeysUnavailable(error.to_string()))?
146 .error_for_status()
147 .map_err(|error| GatewayAuthError::KeysUnavailable(error.to_string()))?
148 .json::<JwkSet>()
149 .await
150 .map_err(|error| GatewayAuthError::KeysUnavailable(error.to_string()))?;
151 if keys.keys.is_empty() {
152 return Err(GatewayAuthError::KeysUnavailable(
153 "JWKS endpoint returned no signing keys".to_owned(),
154 ));
155 }
156 *self.cache.write().await = Some(CachedKeys {
157 keys: keys.clone(),
158 fetched_at: Instant::now(),
159 });
160 Ok(keys)
161 }
162
163 async fn decoding_key(&self, kid: &str) -> Result<DecodingKey, GatewayAuthError> {
164 let cached = self.keys(false).await?;
165 if let Some(jwk) = cached.find(kid) {
166 return DecodingKey::from_jwk(jwk)
167 .map_err(|error| GatewayAuthError::Invalid(error.to_string()));
168 }
169 let refreshed = self.keys(true).await?;
170 let jwk = refreshed.find(kid).ok_or_else(|| {
171 GatewayAuthError::Invalid("JWT signing key id was not found in JWKS".to_owned())
172 })?;
173 DecodingKey::from_jwk(jwk).map_err(|error| GatewayAuthError::Invalid(error.to_string()))
174 }
175}
176
177#[async_trait]
178impl GatewayAuthenticator for JwtGatewayAuthenticator {
179 fn header_name(&self) -> &HeaderName {
180 &self.config.header_name
181 }
182
183 async fn authenticate(&self, token: &str) -> Result<GatewayPrincipal, GatewayAuthError> {
184 let header =
185 decode_header(token).map_err(|error| GatewayAuthError::Invalid(error.to_string()))?;
186 if header.alg != Algorithm::RS256 {
187 return Err(GatewayAuthError::Invalid(
188 "only RS256 gateway assertions are accepted".to_owned(),
189 ));
190 }
191 let kid = header.kid.as_deref().ok_or_else(|| {
192 GatewayAuthError::Invalid("JWT header does not contain a key id".to_owned())
193 })?;
194 let key = self.decoding_key(kid).await?;
195 let mut validation = Validation::new(Algorithm::RS256);
196 validation.set_audience(&[&self.config.audience]);
197 validation.set_issuer(&[&self.config.issuer]);
198 validation.set_required_spec_claims(&["exp", "aud", "iss"]);
199 validation.validate_nbf = true;
200 let claims = decode::<Value>(token, &key, &validation)
201 .map_err(|error| GatewayAuthError::Invalid(error.to_string()))?
202 .claims;
203
204 let mut attributes = BTreeMap::new();
205 for (name, expected) in &self.config.required_claims {
206 let value = claim_at_path(&claims, name).ok_or_else(|| {
207 GatewayAuthError::Invalid(format!("required claim '{name}' is missing"))
208 })?;
209 if !claim_contains(value, expected) {
210 return Err(GatewayAuthError::Invalid(format!(
211 "required claim '{name}' does not match"
212 )));
213 }
214 attributes.insert(name.clone(), expected.clone());
215 }
216 Ok(GatewayPrincipal {
217 subject: claims.get("sub").and_then(Value::as_str).map(str::to_owned),
218 attributes,
219 })
220 }
221}
222
223fn claim_at_path<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
224 path.split('.')
225 .try_fold(claims, |value, segment| value.get(segment))
226}
227
228fn claim_contains(value: &Value, expected: &str) -> bool {
229 match value {
230 Value::String(actual) => actual == expected,
231 Value::Array(values) => values.iter().any(|value| claim_contains(value, expected)),
232 Value::Bool(actual) => expected.parse::<bool>() == Ok(*actual),
233 Value::Number(actual) => actual.to_string() == expected,
234 _ => false,
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use serde_json::json;
242
243 #[test]
244 fn required_claims_support_nested_values_and_group_membership() {
245 let claims = json!({"identity": {"email": "person@example.com"}, "groups": ["dev", "ops"]});
246 assert!(claim_contains(
247 claim_at_path(&claims, "identity.email").unwrap(),
248 "person@example.com"
249 ));
250 assert!(claim_contains(
251 claim_at_path(&claims, "groups").unwrap(),
252 "ops"
253 ));
254 assert!(!claim_contains(
255 claim_at_path(&claims, "groups").unwrap(),
256 "admin"
257 ));
258 }
259
260 #[test]
261 fn gateway_endpoints_require_https_except_for_loopback_tests() {
262 let claims = BTreeMap::from([("email".to_owned(), "person@example.com".to_owned())]);
263 assert!(JwtGatewayConfig::new(
264 "https://access.example.com",
265 "audience",
266 "https://access.example.com/keys",
267 "x-access-jwt",
268 claims.clone(),
269 )
270 .is_ok());
271 assert!(JwtGatewayConfig::new(
272 "http://access.example.com",
273 "audience",
274 "http://access.example.com/keys",
275 "x-access-jwt",
276 claims,
277 )
278 .is_err());
279 }
280}