assay_workflow/
auth_mode.rs1use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use jsonwebtoken::jwk::JwkSet;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15const JWKS_CACHE_TTL: Duration = Duration::from_secs(300); #[derive(Clone, Debug, Default)]
28pub struct AuthMode {
29 pub api_key: bool,
32 pub jwt: Option<JwtConfig>,
35}
36
37#[derive(Clone, Debug)]
39pub struct JwtConfig {
40 pub issuer: String,
41 pub audience: Option<String>,
42 pub jwks_cache: Arc<JwksCache>,
43}
44
45impl AuthMode {
46 pub fn no_auth() -> Self {
48 Self::default()
49 }
50
51 pub fn jwt(issuer: String, audience: Option<String>) -> Self {
53 Self {
54 api_key: false,
55 jwt: Some(JwtConfig::new(issuer, audience)),
56 }
57 }
58
59 pub fn api_key() -> Self {
61 Self {
62 api_key: true,
63 jwt: None,
64 }
65 }
66
67 pub fn combined(issuer: String, audience: Option<String>) -> Self {
70 Self {
71 api_key: true,
72 jwt: Some(JwtConfig::new(issuer, audience)),
73 }
74 }
75
76 pub fn is_enabled(&self) -> bool {
78 self.api_key || self.jwt.is_some()
79 }
80
81 pub fn describe(&self) -> String {
83 match (self.jwt.as_ref(), self.api_key) {
84 (None, false) => "no-auth (open access)".to_string(),
85 (None, true) => "api-key".to_string(),
86 (Some(c), false) => format!("jwt (issuer: {})", c.issuer),
87 (Some(c), true) => format!("jwt (issuer: {}) + api-key", c.issuer),
88 }
89 }
90}
91
92impl JwtConfig {
93 pub fn new(issuer: String, audience: Option<String>) -> Self {
95 Self {
96 jwks_cache: Arc::new(JwksCache::new(issuer.clone())),
97 issuer,
98 audience,
99 }
100 }
101}
102
103pub struct JwksCache {
108 issuer: String,
109 cache: RwLock<Option<CachedJwks>>,
110 http: reqwest::Client,
111}
112
113struct CachedJwks {
114 jwks: JwkSet,
115 fetched_at: Instant,
116}
117
118impl std::fmt::Debug for JwksCache {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("JwksCache")
121 .field("issuer", &self.issuer)
122 .finish()
123 }
124}
125
126impl JwksCache {
127 pub fn new(issuer: String) -> Self {
128 Self {
129 issuer,
130 cache: RwLock::new(None),
131 http: reqwest::Client::builder()
132 .timeout(Duration::from_secs(10))
133 .build()
134 .expect("building JWKS HTTP client"),
135 }
136 }
137
138 pub fn with_jwks(issuer: String, jwks: JwkSet) -> Self {
140 Self {
141 issuer,
142 cache: RwLock::new(Some(CachedJwks {
143 jwks,
144 fetched_at: Instant::now(),
145 })),
146 http: reqwest::Client::new(),
147 }
148 }
149
150 pub async fn get_jwks(&self) -> anyhow::Result<JwkSet> {
152 {
154 let cache = self.cache.read().await;
155 if let Some(ref cached) = *cache
156 && cached.fetched_at.elapsed() < JWKS_CACHE_TTL
157 {
158 return Ok(cached.jwks.clone());
159 }
160 }
161
162 self.refresh().await
164 }
165
166 pub async fn refresh(&self) -> anyhow::Result<JwkSet> {
168 let jwks_uri = self.discover_jwks_uri().await?;
169 debug!("Fetching JWKS from {jwks_uri}");
170
171 let jwks: JwkSet = self.http.get(&jwks_uri).send().await?.json().await?;
172 info!(
173 "Fetched {} keys from JWKS endpoint",
174 jwks.keys.len()
175 );
176
177 let mut cache = self.cache.write().await;
178 *cache = Some(CachedJwks {
179 jwks: jwks.clone(),
180 fetched_at: Instant::now(),
181 });
182
183 Ok(jwks)
184 }
185
186 async fn discover_jwks_uri(&self) -> anyhow::Result<String> {
188 let discovery_url = format!(
189 "{}/.well-known/openid-configuration",
190 self.issuer.trim_end_matches('/')
191 );
192
193 let resp: serde_json::Value = self
194 .http
195 .get(&discovery_url)
196 .send()
197 .await?
198 .json()
199 .await?;
200
201 resp.get("jwks_uri")
202 .and_then(|v| v.as_str())
203 .map(String::from)
204 .ok_or_else(|| anyhow::anyhow!("OIDC discovery response missing jwks_uri"))
205 }
206
207 pub async fn find_key(&self, kid: &str) -> anyhow::Result<jsonwebtoken::DecodingKey> {
210 let jwks = self.get_jwks().await?;
211
212 if let Some(key) = find_key_in_set(&jwks, kid) {
214 return Ok(key);
215 }
216
217 debug!("kid '{kid}' not in JWKS cache, refreshing");
219 let jwks = self.refresh().await?;
220
221 find_key_in_set(&jwks, kid)
222 .ok_or_else(|| anyhow::anyhow!("No key with kid '{kid}' in JWKS"))
223 }
224
225 pub async fn find_any_key(
227 &self,
228 alg: jsonwebtoken::Algorithm,
229 ) -> anyhow::Result<jsonwebtoken::DecodingKey> {
230 let jwks = self.get_jwks().await?;
231
232 for key in &jwks.keys {
233 if let Ok(dk) = jsonwebtoken::DecodingKey::from_jwk(key) {
234 let _ = alg;
235 return Ok(dk);
236 }
237 }
238
239 anyhow::bail!("No suitable key found in JWKS for algorithm {alg:?}")
240 }
241}
242
243fn find_key_in_set(
244 jwks: &JwkSet,
245 kid: &str,
246) -> Option<jsonwebtoken::DecodingKey> {
247 jwks.keys
248 .iter()
249 .find(|k| k.common.key_id.as_deref() == Some(kid))
250 .and_then(|k| jsonwebtoken::DecodingKey::from_jwk(k).ok())
251}