1use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::{Duration, Instant};
6
7use tokio::sync::RwLock;
8
9use crate::TokenProvider;
10use crate::error::AuthError;
11use crate::helpers::{
12 InFlight, duration_from_expires_in_secs, lead_or_follow, oauth_http_client, post_form_url,
13 vendor_rejected_summary,
14};
15
16const DEFAULT_LIFETIME_SECS: u64 = 3600;
17const DEFAULT_SCOPE: &str = "https://management.azure.com/.default";
18
19struct CachedToken {
20 access_token: String,
21 acquired_at: Instant,
22 lifetime: Duration,
23}
24
25impl CachedToken {
26 fn needs_refresh(&self) -> bool {
27 self.acquired_at.elapsed() >= self.lifetime.mul_f64(0.8)
28 }
29}
30
31#[derive(Clone)]
33pub struct AzureTokenProvider {
34 inner: Arc<Inner>,
35}
36
37struct Inner {
38 tenant_id: String,
39 client_id: String,
40 client_secret: String,
41 scope: String,
42 token_url: String,
43 http: reqwest::Client,
44 force_refresh: AtomicBool,
45 state: RwLock<Option<CachedToken>>,
46 inflight: InFlight,
47}
48
49impl std::fmt::Debug for AzureTokenProvider {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_struct("AzureTokenProvider")
52 .field("tenant_id", &self.inner.tenant_id)
53 .field("client_id", &self.inner.client_id)
54 .field("client_secret", &"[REDACTED]")
55 .field("scope", &self.inner.scope)
56 .field("token_url", &self.inner.token_url)
57 .finish()
58 }
59}
60
61impl AzureTokenProvider {
62 pub fn new(
64 tenant_id: impl Into<String>,
65 client_id: impl Into<String>,
66 client_secret: impl Into<String>,
67 scope: impl Into<String>,
68 ) -> Result<Self, AuthError> {
69 let tenant_id = tenant_id.into();
70 let client_id = client_id.into();
71 let client_secret = client_secret.into();
72 let mut scope = scope.into();
73 if scope.trim().is_empty() {
74 scope = DEFAULT_SCOPE.to_string();
75 }
76 if tenant_id.trim().is_empty() {
77 return Err(AuthError::MissingField("Azure tenant_id".into()));
78 }
79 if client_id.trim().is_empty() {
80 return Err(AuthError::MissingField("Azure client_id".into()));
81 }
82 if client_secret.trim().is_empty() {
83 return Err(AuthError::MissingField("Azure client_secret".into()));
84 }
85 let token_url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
86 Ok(Self {
87 inner: Arc::new(Inner {
88 tenant_id,
89 client_id,
90 client_secret,
91 scope,
92 token_url,
93 http: oauth_http_client()?,
94 force_refresh: AtomicBool::new(false),
95 state: RwLock::new(None),
96 inflight: InFlight::new(),
97 }),
98 })
99 }
100
101 #[must_use]
103 pub fn with_token_url(self, token_url: impl Into<String>) -> Self {
104 let mut inner = Arc::try_unwrap(self.inner).unwrap_or_else(|arc| (*arc).clone_inner());
105 inner.token_url = token_url.into();
106 Self {
107 inner: Arc::new(inner),
108 }
109 }
110
111 async fn refresh(&self, force: bool) -> Result<String, AuthError> {
112 {
113 let state = self.inner.state.read().await;
114 if !force
115 && let Some(tok) = state.as_ref()
116 && !tok.needs_refresh()
117 {
118 return Ok(tok.access_token.clone());
119 }
120 }
121 let form = [
122 ("client_id", self.inner.client_id.as_str()),
123 ("client_secret", self.inner.client_secret.as_str()),
124 ("scope", self.inner.scope.as_str()),
125 ("grant_type", "client_credentials"),
126 ];
127 let (status, body) = post_form_url(&self.inner.http, &self.inner.token_url, &form).await?;
128 if !(200..300).contains(&status) {
129 return Err(AuthError::VendorRejected {
130 status,
131 summary: vendor_rejected_summary(
132 "Azure token request failed",
133 &body,
134 &self.inner.token_url,
135 ),
136 });
137 }
138 let parsed = parse_azure_token(&body)?;
139 if parsed.access_token.trim().is_empty() {
140 return Err(AuthError::EmptyWriteRefused);
141 }
142 let lifetime =
143 duration_from_expires_in_secs(parsed.expires_in.unwrap_or(DEFAULT_LIFETIME_SECS));
144 let token = parsed.access_token.clone();
145 *self.inner.state.write().await = Some(CachedToken {
146 access_token: parsed.access_token,
147 acquired_at: Instant::now(),
148 lifetime,
149 });
150 Ok(token)
151 }
152}
153
154impl Inner {
155 fn clone_inner(&self) -> Self {
156 Self {
157 tenant_id: self.tenant_id.clone(),
158 client_id: self.client_id.clone(),
159 client_secret: self.client_secret.clone(),
160 scope: self.scope.clone(),
161 token_url: self.token_url.clone(),
162 http: self.http.clone(),
163 force_refresh: AtomicBool::new(self.force_refresh.load(Ordering::SeqCst)),
164 state: RwLock::new(None),
165 inflight: InFlight::new(),
166 }
167 }
168}
169
170struct ParsedAzureToken {
171 access_token: String,
172 expires_in: Option<u64>,
173}
174
175fn parse_azure_token(body: &str) -> Result<ParsedAzureToken, AuthError> {
176 let doc: serde_json::Value = serde_json::from_str(body)
177 .map_err(|e| AuthError::TokenProvider(format!("Azure token: invalid JSON: {e}")))?;
178 let access_token = doc
179 .get("access_token")
180 .and_then(|v| v.as_str())
181 .unwrap_or("")
182 .to_string();
183 if access_token.is_empty() {
184 return Err(AuthError::TokenProvider(
185 "Azure token response has no access_token".into(),
186 ));
187 }
188 let expires_in = doc.get("expires_in").and_then(|v| {
189 v.as_u64()
190 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
191 });
192 Ok(ParsedAzureToken {
193 access_token,
194 expires_in,
195 })
196}
197
198impl TokenProvider for AzureTokenProvider {
199 fn mark_stale(&self) {
200 self.inner.force_refresh.store(true, Ordering::SeqCst);
201 }
202
203 async fn get_token(&self) -> Result<String, AuthError> {
204 {
207 let state = self.inner.state.read().await;
208 if !self.inner.force_refresh.load(Ordering::SeqCst)
209 && let Some(tok) = state.as_ref()
210 && !tok.needs_refresh()
211 && !self.inner.inflight.is_busy()
212 {
213 return Ok(tok.access_token.clone());
214 }
215 }
216 lead_or_follow(&self.inner.inflight, || async {
217 let force = self.inner.force_refresh.swap(false, Ordering::SeqCst);
218 let result = self.refresh(force).await;
219 if result.is_err() && force {
220 self.inner.force_refresh.store(true, Ordering::SeqCst);
221 }
222 result
223 })
224 .await
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use std::io::{Read, Write};
232 use std::net::TcpListener;
233 use std::thread;
234 use std::time::{Duration, Instant};
235
236 fn read_http_headers(stream: &mut impl Read) -> Vec<u8> {
237 let mut buf = Vec::new();
238 let mut tmp = [0u8; 1024];
239 loop {
240 match stream.read(&mut tmp) {
241 Ok(0) => break,
242 Ok(n) => {
243 buf.extend_from_slice(&tmp[..n]);
244 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
245 break;
246 }
247 }
248 Err(_) => break,
249 }
250 }
251 buf
252 }
253
254 fn write_http_json(stream: &mut impl Write, status: u16, body: &str) {
255 let resp = format!(
256 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
257 body.len()
258 );
259 let _ = stream.write_all(resp.as_bytes());
260 }
261
262 fn spawn_http_server(status: u16, body: &str) -> (String, thread::JoinHandle<String>) {
263 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
264 let addr = listener.local_addr().expect("local_addr");
265 let body = body.to_owned();
266 let handle = thread::spawn(move || {
267 let (mut stream, _) = listener.accept().expect("accept");
268 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
269 let buf = read_http_headers(&mut stream);
270 write_http_json(&mut stream, status, &body);
271 String::from_utf8_lossy(&buf).into_owned()
272 });
273 (format!("http://{addr}/oauth/token"), handle)
274 }
275
276 fn spawn_http_script(responses: &[(u16, &str)]) -> (String, thread::JoinHandle<usize>) {
277 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
278 let addr = listener.local_addr().expect("local_addr");
279 let responses: Vec<(u16, String)> = responses
280 .iter()
281 .map(|(status, body)| (*status, (*body).to_owned()))
282 .collect();
283 let handle = thread::spawn(move || {
284 let mut served = 0usize;
285 for (status, body) in responses {
286 let (mut stream, _) = listener.accept().expect("accept");
287 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
288 let _ = read_http_headers(&mut stream);
289 write_http_json(&mut stream, status, &body);
290 served += 1;
291 }
292 served
293 });
294 (format!("http://{addr}/oauth/token"), handle)
295 }
296
297 fn spawn_http_cache_then_refresh(
299 first: &str,
300 refreshed: &str,
301 refresh_delay: Duration,
302 extra_idle: Duration,
303 ) -> (String, thread::JoinHandle<usize>) {
304 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
305 listener.set_nonblocking(true).expect("nonblocking");
306 let addr = listener.local_addr().expect("local_addr");
307 let first = first.to_owned();
308 let refreshed = refreshed.to_owned();
309 let handle = thread::spawn(move || {
310 let mut served = 0usize;
311 let first_deadline = Instant::now() + Duration::from_secs(5);
312 let mut extra_deadline: Option<Instant> = None;
313 while served < 3 {
314 match listener.accept() {
315 Ok((mut stream, _)) => {
316 stream.set_nonblocking(false).ok();
317 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
318 let _ = read_http_headers(&mut stream);
319 if served == 0 {
320 write_http_json(&mut stream, 200, &first);
321 } else {
322 if served == 1 && !refresh_delay.is_zero() {
323 thread::sleep(refresh_delay);
324 }
325 write_http_json(&mut stream, 200, &refreshed);
326 extra_deadline = Some(Instant::now() + extra_idle);
327 }
328 served += 1;
329 }
330 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
331 let limit = extra_deadline.unwrap_or(first_deadline);
332 if Instant::now() >= limit {
333 break;
334 }
335 thread::sleep(Duration::from_millis(5));
336 }
337 Err(_) => break,
338 }
339 }
340 served
341 });
342 (format!("http://{addr}/oauth/token"), handle)
343 }
344
345 #[tokio::test]
346 async fn azure_client_credentials_returns_access_token() {
347 let (url, handle) = spawn_http_server(
348 200,
349 r#"{"access_token":"az-tok","expires_in":3600,"token_type":"Bearer"}"#,
350 );
351 let p = AzureTokenProvider::new("tenant", "client", "secret", "https://example/.default")
352 .expect("new")
353 .with_token_url(url);
354 assert_eq!(p.get_token().await.expect("token"), "az-tok");
355 let req = handle.join().expect("join");
356 assert!(req.contains("grant_type=client_credentials"), "{req}");
357 assert!(req.contains("client_id=client"), "{req}");
358 assert!(req.contains("client_secret=secret"), "{req}");
359 }
360
361 #[tokio::test]
362 async fn azure_cached_token_skips_second_http() {
363 let (url, handle) =
364 spawn_http_server(200, r#"{"access_token":"az-tok","expires_in":3600}"#);
365 let p = AzureTokenProvider::new("tenant", "client", "secret", "scope")
366 .expect("new")
367 .with_token_url(url);
368 assert_eq!(p.get_token().await.expect("first"), "az-tok");
369 assert_eq!(p.get_token().await.expect("cached"), "az-tok");
370 let _ = handle.join();
371 }
372
373 #[test]
374 fn azure_debug_redacts_secret() {
375 let p = AzureTokenProvider::new("tenant", "client", "super-secret", "scope").expect("new");
376 let debug = format!("{p:?}");
377 assert!(!debug.contains("super-secret"), "{debug}");
378 assert!(debug.contains("[REDACTED]"), "{debug}");
379 }
380
381 #[test]
382 fn azure_empty_secret_fails() {
383 let err = AzureTokenProvider::new("t", "c", "", "s").expect_err("empty");
384 assert!(err.to_string().contains("client_secret"), "{err}");
385 }
386
387 #[tokio::test]
388 async fn forced_refresh_http_error_restores_force_flag() {
389 let (url, handle) = spawn_http_script(&[
390 (200, r#"{"access_token":"az-first","expires_in":3600}"#),
391 (401, r#"{"error":"invalid_client"}"#),
392 (200, r#"{"access_token":"az-retry","expires_in":3600}"#),
393 ]);
394 let p = AzureTokenProvider::new("tenant", "client", "secret", "scope")
395 .expect("new")
396 .with_token_url(url);
397 assert_eq!(p.get_token().await.expect("cache"), "az-first");
398 p.mark_stale();
399 let err = p.get_token().await.expect_err("forced 401");
400 let msg = err.to_string();
401 assert!(msg.contains("401") || msg.contains("Azure"), "{msg}");
402 assert_eq!(
403 msg.matches("(HTTP ").count(),
404 1,
405 "VendorRejected must not re-wrap HTTP: {msg}"
406 );
407 assert_eq!(
408 p.get_token().await.expect("retry after failed force"),
409 "az-retry"
410 );
411 assert_eq!(handle.join().expect("join"), 3);
412 }
413
414 #[tokio::test]
415 async fn concurrent_mark_stale_does_not_serve_stale_cache() {
416 let first = r#"{"access_token":"az-first","expires_in":3600}"#;
417 let second = r#"{"access_token":"az-second","expires_in":3600}"#;
418 let (url, handle) = spawn_http_cache_then_refresh(
419 first,
420 second,
421 Duration::from_millis(80),
422 Duration::from_millis(200),
423 );
424 let p = AzureTokenProvider::new("tenant", "client", "secret", "scope")
425 .expect("new")
426 .with_token_url(url);
427 assert_eq!(p.get_token().await.expect("cache"), "az-first");
428 p.mark_stale();
429 let a = p.clone();
430 let b = p.clone();
431 let (left, right) = tokio::join!(a.get_token(), b.get_token());
432 let left = left.expect("left");
433 let right = right.expect("right");
434 assert_ne!(left, "az-first", "must not return just-staled cache");
435 assert_ne!(right, "az-first", "must not return just-staled cache");
436 assert_eq!(left, "az-second");
437 assert_eq!(right, "az-second");
438 let served = handle.join().expect("join");
439 assert!(
440 served >= 2,
441 "cache fill plus at least one refresh, got {served}"
442 );
443 }
444}