Skip to main content

everymap_core/auth/
oauth2.rs

1use crate::auth::provider::AuthProvider;
2use crate::client::truncate_str;
3use crate::error::{EveryMapError, EveryMapResult};
4use async_trait::async_trait;
5use reqwest::header::{HeaderValue, AUTHORIZATION};
6use reqwest::RequestBuilder;
7use serde::Deserialize;
8use std::time::{Duration, Instant};
9use tokio::sync::Mutex;
10use zeroize::Zeroize;
11
12/// Seconds shaved off the token lifetime so tokens refresh before actual expiry.
13const TOKEN_EXPIRY_SKEW_SECS: u64 = 30;
14
15/// Fallback token lifetime (seconds) when the token response omits `expires_in`.
16const DEFAULT_EXPIRES_IN_SECS: u64 = 3600;
17
18/// Maximum bytes of error body included in auth error messages.
19const ERROR_BODY_LIMIT: usize = 256;
20
21/// Provider name used in error messages.
22const OAUTH2_PROVIDER_NAME: &str = "oauth2";
23
24/// Provider for OAuth 2.0 client-credentials authentication.
25///
26/// On each request, ensures a valid cached access token (fetching one from the
27/// token endpoint via the `client_credentials` grant when missing or expired)
28/// and injects it as an `Authorization: Bearer <token>` header.
29///
30/// The client secret and cached tokens are zeroized on drop to prevent
31/// lingering sensitive data in memory.
32pub struct OAuth2Provider {
33    credentials: ClientCredentials,
34    token_endpoint: String,
35    http_client: reqwest::Client,
36    cached_token: Mutex<Option<CachedToken>>,
37}
38
39/// OAuth 2.0 client credentials (zeroized on drop).
40#[derive(Zeroize)]
41#[zeroize(drop)]
42struct ClientCredentials {
43    client_id: String,
44    client_secret: String,
45    scope: Option<String>,
46}
47
48/// A cached access token with its expiry instant (zeroized on drop).
49#[derive(Zeroize)]
50#[zeroize(drop)]
51struct CachedToken {
52    access_token: String,
53    #[zeroize(skip)]
54    expires_at: Instant,
55}
56
57/// Token endpoint response (`client_credentials` grant).
58#[derive(Debug, Deserialize)]
59struct TokenResponse {
60    access_token: String,
61    #[serde(default)]
62    expires_in: Option<u64>,
63}
64
65impl OAuth2Provider {
66    /// Create a new OAuth 2.0 client-credentials auth provider.
67    ///
68    /// `token_endpoint` is the full URL of the OAuth 2.0 token endpoint.
69    /// `scope` is optional and sent as the `scope` form parameter when present.
70    pub fn new(
71        token_endpoint: String,
72        client_id: String,
73        client_secret: String,
74        scope: Option<String>,
75    ) -> Self {
76        Self {
77            credentials: ClientCredentials {
78                client_id,
79                client_secret,
80                scope,
81            },
82            token_endpoint,
83            http_client: reqwest::Client::new(),
84            cached_token: Mutex::new(None),
85        }
86    }
87
88    /// Return a valid cached access token, fetching a new one when missing or expired.
89    ///
90    /// The fetch happens while holding the token lock, so concurrent callers
91    /// share a single token request instead of stampeding the token endpoint.
92    async fn access_token(&self) -> EveryMapResult<String> {
93        let mut cached = self.cached_token.lock().await;
94        if let Some(token) = cached.as_ref() {
95            if Instant::now() < token.expires_at {
96                return Ok(token.access_token.clone());
97            }
98        }
99        let new_token = self.fetch_token().await?;
100        let access_token = new_token.access_token.clone();
101        *cached = Some(new_token);
102        Ok(access_token)
103    }
104
105    /// Fetch a fresh access token from the token endpoint via the
106    /// `client_credentials` grant (`POST`, `application/x-www-form-urlencoded`).
107    async fn fetch_token(&self) -> EveryMapResult<CachedToken> {
108        let mut form_pairs: Vec<(&str, &str)> = vec![
109            ("grant_type", "client_credentials"),
110            ("client_id", &self.credentials.client_id),
111            ("client_secret", &self.credentials.client_secret),
112        ];
113        if let Some(scope) = &self.credentials.scope {
114            form_pairs.push(("scope", scope));
115        }
116
117        let response = self
118            .http_client
119            .post(&self.token_endpoint)
120            .form(&form_pairs)
121            .send()
122            .await?;
123
124        let status = response.status();
125        let body = response.text().await?;
126        if !status.is_success() {
127            return Err(EveryMapError::auth(
128                OAUTH2_PROVIDER_NAME,
129                format!(
130                    "Token endpoint returned HTTP {}: {}",
131                    status.as_u16(),
132                    truncate_str(&body, ERROR_BODY_LIMIT)
133                ),
134            ));
135        }
136
137        let token_response: TokenResponse = serde_json::from_str(&body).map_err(|error| {
138            EveryMapError::auth(
139                OAUTH2_PROVIDER_NAME,
140                format!(
141                    "Failed to parse token response: {} (body: {})",
142                    error,
143                    truncate_str(&body, ERROR_BODY_LIMIT)
144                ),
145            )
146        })?;
147
148        Ok(CachedToken {
149            access_token: token_response.access_token,
150            expires_at: expires_at_from(
151                token_response.expires_in.unwrap_or(DEFAULT_EXPIRES_IN_SECS),
152            ),
153        })
154    }
155}
156
157#[async_trait]
158impl AuthProvider for OAuth2Provider {
159    async fn apply(&self, request: RequestBuilder) -> EveryMapResult<RequestBuilder> {
160        let access_token = self.access_token().await?;
161        let header_value =
162            HeaderValue::from_str(&format!("Bearer {}", access_token)).map_err(|error| {
163                EveryMapError::auth(
164                    OAUTH2_PROVIDER_NAME,
165                    format!("Invalid bearer token header value: {}", error),
166                )
167            })?;
168        Ok(request.header(AUTHORIZATION, header_value))
169    }
170}
171
172/// Compute the cached-token expiry instant: `now + expires_in - safety skew`.
173fn expires_at_from(expires_in_secs: u64) -> Instant {
174    Instant::now() + Duration::from_secs(expires_in_secs.saturating_sub(TOKEN_EXPIRY_SKEW_SECS))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_expires_at_respects_skew() {
183        let expires_at = expires_at_from(3600);
184        let remaining = expires_at.checked_duration_since(Instant::now());
185        let remaining = remaining.expect("token must not be expired");
186        assert!(remaining <= Duration::from_secs(3600 - TOKEN_EXPIRY_SKEW_SECS));
187        assert!(remaining >= Duration::from_secs(3600 - TOKEN_EXPIRY_SKEW_SECS - 5));
188    }
189
190    #[test]
191    fn test_expires_at_zero_lifetime_is_immediately_expired() {
192        let expires_at = expires_at_from(0);
193        assert!(Instant::now() >= expires_at);
194    }
195
196    #[test]
197    fn test_expires_at_lifetime_below_skew_is_immediately_expired() {
198        let expires_at = expires_at_from(TOKEN_EXPIRY_SKEW_SECS - 1);
199        assert!(Instant::now() >= expires_at);
200    }
201
202    #[test]
203    fn test_token_response_full() {
204        let token_response: TokenResponse =
205            serde_json::from_str(r#"{"access_token":"tok","expires_in":7200}"#).unwrap();
206        assert_eq!(token_response.access_token, "tok");
207        assert_eq!(token_response.expires_in, Some(7200));
208    }
209
210    #[test]
211    fn test_token_response_without_expires_in() {
212        let token_response: TokenResponse =
213            serde_json::from_str(r#"{"access_token":"tok"}"#).unwrap();
214        assert_eq!(token_response.access_token, "tok");
215        assert_eq!(token_response.expires_in, None);
216    }
217
218    #[test]
219    fn test_token_response_missing_access_token_fails() {
220        let parse_result = serde_json::from_str::<TokenResponse>(r#"{"expires_in":60}"#);
221        assert!(parse_result.is_err());
222    }
223}