everymap_core/auth/
oauth2.rs1use 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
12const TOKEN_EXPIRY_SKEW_SECS: u64 = 30;
14
15const DEFAULT_EXPIRES_IN_SECS: u64 = 3600;
17
18const ERROR_BODY_LIMIT: usize = 256;
20
21const OAUTH2_PROVIDER_NAME: &str = "oauth2";
23
24pub struct OAuth2Provider {
33 credentials: ClientCredentials,
34 token_endpoint: String,
35 http_client: reqwest::Client,
36 cached_token: Mutex<Option<CachedToken>>,
37}
38
39#[derive(Zeroize)]
41#[zeroize(drop)]
42struct ClientCredentials {
43 client_id: String,
44 client_secret: String,
45 scope: Option<String>,
46}
47
48#[derive(Zeroize)]
50#[zeroize(drop)]
51struct CachedToken {
52 access_token: String,
53 #[zeroize(skip)]
54 expires_at: Instant,
55}
56
57#[derive(Debug, Deserialize)]
59struct TokenResponse {
60 access_token: String,
61 #[serde(default)]
62 expires_in: Option<u64>,
63}
64
65impl OAuth2Provider {
66 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 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 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
172fn 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}