Skip to main content

camel_auth/
oauth2.rs

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    /// Creates a production OAuth2 provider with HTTPS enforcement,
83    /// SSRF guard, DNS-rebinding protection, and hardened timeouts.
84    ///
85    /// Validates the token endpoint is a public HTTPS URI, resolves DNS
86    /// and pins validated IPs on the HTTP client — closing the TOCTOU
87    /// window between validation and the first outbound token request.
88    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    /// Test-only constructor that accepts a pre-built HTTP client.
117    ///
118    /// Skips SSRF validation and allows injecting a mock-capable client
119    /// (e.g. `wiremock`-configured). Do NOT use in production code.
120    #[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(&params)
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}"); // allow-secret
174            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        // validate_uri catches IP literals like
234        // 169.254.169.254 before DNS resolution
235        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()), // allow-secret
269            "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()), // allow-secret
290            "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()), // allow-secret
317            "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()), // allow-secret
340            "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()), // allow-secret
363            "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()), // allow-secret
388            "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()), // allow-secret
413            "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}