1use async_trait::async_trait;
2use serde::Deserialize;
3use serde::de::Deserializer;
4use std::time::{Duration, Instant};
5use tokio::sync::{Mutex, RwLock};
6use zeroize::Zeroizing;
7
8use camel_api::SsrfPolicy;
9
10use crate::http_client::{SsrfClientOptions, build_ssrf_pinned_client};
11use crate::types::AuthError;
12
13fn deserialize_zeroizing_string<'de, D>(deserializer: D) -> Result<Zeroizing<String>, D::Error>
14where
15 D: Deserializer<'de>,
16{
17 let s = String::deserialize(deserializer)?;
18 Ok(Zeroizing::new(s))
19}
20
21const DEFAULT_SKEW: Duration = Duration::from_secs(30);
22
23#[async_trait]
24pub trait TokenProvider: Send + Sync + std::fmt::Debug {
25 async fn get_token(&self) -> Result<String, AuthError>;
26}
27
28#[derive(Debug, Deserialize)]
29struct TokenResponse {
30 #[serde(deserialize_with = "deserialize_zeroizing_string")]
31 access_token: Zeroizing<String>,
32 #[allow(dead_code)]
33 token_type: String,
34 expires_in: u64,
35}
36
37struct CachedToken {
38 access_token: Zeroizing<String>,
39 #[allow(dead_code)]
40 expires_at: Instant,
41 refresh_at: Instant,
42}
43
44impl CachedToken {
45 fn new(access_token: Zeroizing<String>, expires_in: Duration, skew: Duration) -> Self {
46 let expires_at = Instant::now() + expires_in;
47 Self {
48 access_token,
49 refresh_at: expires_at.checked_sub(skew).unwrap_or(expires_at),
50 expires_at,
51 }
52 }
53
54 fn is_usable(&self) -> bool {
55 Instant::now() < self.refresh_at
56 }
57}
58
59pub struct ClientCredentialsProvider {
60 token_endpoint: String,
61 client_id: String,
62 client_secret: Zeroizing<String>,
63 scope: Option<String>,
64 audience: Option<Vec<String>>,
65 cache: RwLock<Option<CachedToken>>,
66 refresh_lock: Mutex<()>,
67 http: reqwest::Client,
68}
69
70impl std::fmt::Debug for ClientCredentialsProvider {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.debug_struct("ClientCredentialsProvider")
73 .field("token_endpoint", &self.token_endpoint)
74 .field("client_id", &self.client_id)
75 .field("scope", &self.scope)
76 .field("audience", &self.audience)
77 .finish_non_exhaustive()
78 }
79}
80
81impl ClientCredentialsProvider {
82 pub async fn new(
89 token_endpoint: String,
90 client_id: String,
91 client_secret: String,
92 scope: Option<String>,
93 audience: Option<Vec<String>>,
94 policy: SsrfPolicy,
95 ) -> Result<Self, AuthError> {
96 let http = build_ssrf_pinned_client(
97 &token_endpoint,
98 "OAuth2 token endpoint",
99 &SsrfClientOptions::new(policy)
100 .with_connect_timeout(Duration::from_secs(10))
101 .with_request_timeout(Duration::from_secs(30)),
102 )
103 .await?;
104 Ok(Self {
105 token_endpoint,
106 client_id,
107 client_secret: Zeroizing::new(client_secret),
108 scope,
109 audience,
110 cache: RwLock::new(None),
111 refresh_lock: Mutex::new(()),
112 http,
113 })
114 }
115
116 #[doc(hidden)]
121 pub fn new_unchecked_for_test(
122 token_endpoint: String,
123 client_id: String,
124 client_secret: String,
125 scope: Option<String>,
126 audience: Option<Vec<String>>,
127 http: reqwest::Client,
128 ) -> Self {
129 Self {
130 token_endpoint,
131 client_id,
132 client_secret: Zeroizing::new(client_secret),
133 scope,
134 audience,
135 cache: RwLock::new(None),
136 refresh_lock: Mutex::new(()),
137 http,
138 }
139 }
140
141 async fn fetch_token(&self) -> Result<CachedToken, AuthError> {
142 let secret = self.client_secret.as_str();
143 let mut params: Vec<(&str, &str)> = vec![
144 ("grant_type", "client_credentials"),
145 ("client_id", &self.client_id),
146 ("client_secret", secret),
147 ];
148 if let Some(ref scope) = self.scope {
149 params.push(("scope", scope));
150 }
151 if let Some(ref audience) = self.audience {
152 for aud in audience {
153 params.push(("resource", aud));
154 }
155 }
156
157 let resp = self
158 .http
159 .post(&self.token_endpoint)
160 .form(¶ms)
161 .send()
162 .await
163 .map_err(|e| AuthError::ProviderUnavailable(format!("OAuth2 request failed: {e}")))?;
164
165 if !resp.status().is_success() {
166 let status = resp.status();
167 let body = resp.text().await.unwrap_or_default();
168 let sanitized = if body.len() > 128 {
169 format!("{}...(truncated)", &body[..128])
170 } else {
171 body
172 };
173 let message = format!("token endpoint returned {status}: {sanitized}"); return Err(AuthError::ProviderUnavailable(message));
175 }
176
177 let token_resp: TokenResponse = resp
178 .json()
179 .await
180 .map_err(|e| AuthError::ProviderUnavailable(format!("invalid OAuth2 response: {e}")))?;
181
182 Ok(CachedToken::new(
183 token_resp.access_token,
184 Duration::from_secs(token_resp.expires_in),
185 DEFAULT_SKEW,
186 ))
187 }
188}
189
190#[async_trait]
191impl TokenProvider for ClientCredentialsProvider {
192 async fn get_token(&self) -> Result<String, AuthError> {
193 {
194 let cache = self.cache.read().await;
195 if let Some(ref cached) = *cache
196 && cached.is_usable()
197 {
198 return Ok(cached.access_token.as_str().to_owned());
199 }
200 }
201
202 let _guard = self.refresh_lock.lock().await;
203
204 {
205 let cache = self.cache.read().await;
206 if let Some(ref cached) = *cache
207 && cached.is_usable()
208 {
209 return Ok(cached.access_token.as_str().to_owned());
210 }
211 }
212
213 let cached = self.fetch_token().await?;
214 let token = cached.access_token.as_str().to_owned();
215 {
216 let mut cache = self.cache.write().await;
217 *cache = Some(cached);
218 }
219 Ok(token)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use std::sync::Arc;
226
227 use super::*;
228 use wiremock::matchers::{body_string_contains, method, path};
229 use wiremock::{Mock, MockServer, ResponseTemplate};
230
231 #[tokio::test]
232 async fn oauth2_rejects_private_ip_token_endpoint() {
233 let result = ClientCredentialsProvider::new(
236 "https://169.254.169.254/token".into(),
237 "client".into(),
238 "secret".into(),
239 None,
240 None,
241 SsrfPolicy::PublicHttpsOnly,
242 )
243 .await;
244 assert!(
245 result.is_err(),
246 "private IP token endpoint should be rejected"
247 );
248 }
249
250 fn token_response(access_token: &str, expires_in: u64) -> serde_json::Value {
251 serde_json::json!({
252 "access_token": access_token,
253 "token_type": "Bearer",
254 "expires_in": expires_in,
255 })
256 }
257
258 #[tokio::test]
259 async fn test_get_token_fresh() {
260 let server = MockServer::start().await;
261 Mock::given(method("POST"))
262 .and(path("/protocol/openid-connect/token"))
263 .respond_with(ResponseTemplate::new(200).set_body_json(token_response("abc123", 300)))
264 .mount(&server)
265 .await;
266
267 let provider = ClientCredentialsProvider::new_unchecked_for_test(
268 format!("{}/protocol/openid-connect/token", server.uri()), "test-client".into(),
270 "test-secret".into(),
271 None,
272 None,
273 reqwest::Client::new(),
274 );
275 let token = provider.get_token().await.unwrap();
276 assert_eq!(token, "abc123");
277 }
278
279 #[tokio::test]
280 async fn test_get_token_uses_cache() {
281 let server = MockServer::start().await;
282 Mock::given(method("POST"))
283 .respond_with(ResponseTemplate::new(200).set_body_json(token_response("cached", 300)))
284 .expect(1)
285 .mount(&server)
286 .await;
287
288 let provider = ClientCredentialsProvider::new_unchecked_for_test(
289 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
291 "s".into(),
292 None,
293 None,
294 reqwest::Client::new(),
295 );
296 let t1 = provider.get_token().await.unwrap();
297 let t2 = provider.get_token().await.unwrap();
298 assert_eq!(t1, "cached");
299 assert_eq!(t2, "cached");
300 }
301
302 #[tokio::test]
303 async fn test_get_token_refreshes_when_stale() {
304 let server = MockServer::start().await;
305 Mock::given(method("POST"))
306 .respond_with(ResponseTemplate::new(200).set_body_json(token_response("first", 1)))
307 .up_to_n_times(1)
308 .mount(&server)
309 .await;
310 Mock::given(method("POST"))
311 .respond_with(ResponseTemplate::new(200).set_body_json(token_response("second", 300)))
312 .mount(&server)
313 .await;
314
315 let provider = ClientCredentialsProvider::new_unchecked_for_test(
316 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
318 "s".into(),
319 None,
320 None,
321 reqwest::Client::new(),
322 );
323 let t1 = provider.get_token().await.unwrap();
324 assert_eq!(t1, "first");
325 tokio::time::sleep(Duration::from_millis(1100)).await;
326 let t2 = provider.get_token().await.unwrap();
327 assert_eq!(t2, "second");
328 }
329
330 #[tokio::test]
331 async fn test_get_token_server_error() {
332 let server = MockServer::start().await;
333 Mock::given(method("POST"))
334 .respond_with(ResponseTemplate::new(500))
335 .mount(&server)
336 .await;
337
338 let provider = ClientCredentialsProvider::new_unchecked_for_test(
339 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
341 "s".into(),
342 None,
343 None,
344 reqwest::Client::new(),
345 );
346 let err = provider.get_token().await.unwrap_err();
347 assert!(matches!(err, AuthError::ProviderUnavailable(_)));
348 }
349
350 #[tokio::test]
351 async fn test_get_token_invalid_response() {
352 let server = MockServer::start().await;
353 Mock::given(method("POST"))
354 .respond_with(
355 ResponseTemplate::new(200)
356 .set_body_json(serde_json::json!({"error": "invalid_grant"})),
357 )
358 .mount(&server)
359 .await;
360
361 let provider = ClientCredentialsProvider::new_unchecked_for_test(
362 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
364 "s".into(),
365 None,
366 None,
367 reqwest::Client::new(),
368 );
369 let err = provider.get_token().await.unwrap_err();
370 assert!(matches!(err, AuthError::ProviderUnavailable(_)));
371 }
372
373 #[tokio::test]
374 async fn test_get_token_sends_audience_as_resource() {
375 let server = MockServer::start().await;
376 Mock::given(method("POST"))
377 .and(body_string_contains(
378 "resource=https%3A%2F%2Fapi.example.com",
379 ))
380 .respond_with(
381 ResponseTemplate::new(200).set_body_json(token_response("aud-token", 300)),
382 )
383 .mount(&server)
384 .await;
385
386 let provider = ClientCredentialsProvider::new_unchecked_for_test(
387 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
389 "s".into(),
390 None,
391 Some(vec!["https://api.example.com".into()]),
392 reqwest::Client::new(),
393 );
394 let token = provider.get_token().await.unwrap();
395 assert_eq!(token, "aud-token");
396 }
397
398 #[tokio::test]
399 async fn test_single_flight_concurrent_callers() {
400 let server = MockServer::start().await;
401 Mock::given(method("POST"))
402 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
403 "access_token": "single-flight-token",
404 "token_type": "Bearer",
405 "expires_in": 300,
406 })))
407 .expect(1)
408 .mount(&server)
409 .await;
410
411 let provider = Arc::new(ClientCredentialsProvider::new_unchecked_for_test(
412 format!("{}/protocol/openid-connect/token", server.uri()), "c".into(),
414 "s".into(),
415 None,
416 None,
417 reqwest::Client::new(),
418 ));
419
420 let mut handles = vec![];
421 for _ in 0..5 {
422 let p = Arc::clone(&provider);
423 handles.push(tokio::spawn(async move { p.get_token().await }));
424 }
425 for h in handles {
426 let token = h.await.unwrap().unwrap();
427 assert_eq!(token, "single-flight-token");
428 }
429 }
430}