1use std::future::Future;
2use std::sync::Arc;
3
4use cts_common::WorkspaceId;
5use url::Url;
6
7use crate::authorize_dto::AuthoriseResponse;
8use crate::refresher::Refresher;
9use crate::{http_client, AuthError, SecretToken, Token};
10
11#[cfg(not(target_arch = "wasm32"))]
24pub trait OidcProvider: Send + Sync {
25 fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> + Send;
27}
28
29#[cfg(target_arch = "wasm32")]
31pub trait OidcProvider {
32 fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>>;
34}
35
36pub struct OidcProviderFn<F> {
61 fetch: F,
62}
63
64impl<F> OidcProviderFn<F> {
65 pub fn new(fetch: F) -> Self {
67 Self { fetch }
68 }
69}
70
71#[cfg(not(target_arch = "wasm32"))]
72impl<F, Fut> OidcProvider for OidcProviderFn<F>
73where
74 F: Fn() -> Fut + Send + Sync,
75 Fut: Future<Output = Result<SecretToken, AuthError>> + Send,
76{
77 fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> + Send {
78 (self.fetch)()
79 }
80}
81
82#[cfg(target_arch = "wasm32")]
83impl<F, Fut> OidcProvider for OidcProviderFn<F>
84where
85 F: Fn() -> Fut,
86 Fut: Future<Output = Result<SecretToken, AuthError>>,
87{
88 fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> {
89 (self.fetch)()
90 }
91}
92
93pub(crate) struct OidcRefresher<P> {
111 oidc_provider: P,
112 workspace_id: WorkspaceId,
113 base_url: Url,
114 http_client: Arc<reqwest::Client>,
115}
116
117impl<P> OidcRefresher<P> {
118 pub(crate) fn new(oidc_provider: P, workspace_id: WorkspaceId, base_url: Url) -> Self {
119 Self {
120 oidc_provider,
121 workspace_id,
122 base_url,
123 http_client: Arc::new(http_client()),
124 }
125 }
126}
127
128impl<P: OidcProvider> Refresher for OidcRefresher<P> {
129 type Credential = ();
130
131 fn save(&self, _token: &Token) {
132 }
134
135 fn try_credential(&self, _token: Option<&mut Token>) -> Option<Self::Credential> {
136 Some(())
139 }
140
141 fn restore(&self, _token: &mut Token, _credential: Self::Credential) {
142 }
144
145 async fn refresh(&self, _credential: &Self::Credential) -> Result<Token, AuthError> {
146 let oidc_token = self.oidc_provider.fetch().await?;
147
148 let url = self.base_url.join("api/authorise")?;
149 tracing::debug!(url = %url, "federating OIDC token");
150
151 let resp = self
152 .http_client
153 .post(url)
154 .json(&OidcAuthoriseRequest {
155 oidc_token: oidc_token.as_str(),
156 workspace_id: self.workspace_id.as_str(),
157 })
158 .send()
159 .await?;
160
161 if !resp.status().is_success() {
162 let status = resp.status();
163 let body = resp.text().await.unwrap_or_default();
164 tracing::debug!(%status, %body, "OIDC federation failed");
165 return Err(AuthError::Server(crate::error::ServerError(format!(
166 "{status}: {body}"
167 ))));
168 }
169
170 let auth_resp: AuthoriseResponse = resp.json().await?;
171
172 Ok(auth_resp.into())
175 }
176}
177
178#[derive(serde::Serialize)]
179#[serde(rename_all = "camelCase")]
180struct OidcAuthoriseRequest<'a> {
181 oidc_token: &'a str,
182 workspace_id: &'a str,
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)]
187mod tests {
188 use std::sync::atomic::{AtomicUsize, Ordering};
189 use std::sync::Arc;
190 use std::time::{SystemTime, UNIX_EPOCH};
191
192 use mocktail::prelude::*;
193
194 use super::*;
195 use crate::auto_refresh::{AutoRefresh, AutoRefreshError};
196 use crate::TokenStore;
197
198 const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY";
199
200 fn workspace_id() -> WorkspaceId {
201 WORKSPACE_ID.parse().unwrap()
202 }
203
204 fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value {
208 let now = SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .unwrap()
211 .as_secs();
212 serde_json::json!({
213 "accessToken": access,
214 "expiry": now + expires_in_secs
215 })
216 }
217
218 async fn start_server(mocks: MockSet) -> MockServer {
219 let server = MockServer::new_http("oidc-refresher-test").with_mocks(mocks);
220 server.start().await.unwrap();
221 server
222 }
223
224 fn counting_provider() -> (Arc<AtomicUsize>, impl OidcProvider) {
227 let calls = Arc::new(AtomicUsize::new(0));
228 let calls_clone = Arc::clone(&calls);
229 let provider = OidcProviderFn::new(move || {
230 let calls = Arc::clone(&calls_clone);
231 async move {
232 let n = calls.fetch_add(1, Ordering::SeqCst);
233 Ok(SecretToken::new(format!("jwt-{n}")))
234 }
235 });
236 (calls, provider)
237 }
238
239 fn make_strategy<P: OidcProvider>(
240 server: &MockServer,
241 provider: P,
242 ) -> AutoRefresh<OidcRefresher<P>> {
243 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
244 AutoRefresh::with_store(refresher, crate::NoStore)
245 }
246
247 fn make_token(access: &str, expires_in_secs: u64) -> Token {
248 let now = SystemTime::now()
249 .duration_since(UNIX_EPOCH)
250 .unwrap()
251 .as_secs();
252 Token {
253 access_token: SecretToken::new(access),
254 token_type: "Bearer".to_string(),
255 expires_at: now + expires_in_secs,
256 refresh_token: None,
257 region: None,
258 client_id: None,
259 device_instance_id: None,
260 }
261 }
262
263 #[tokio::test]
275 async fn oidc_expiry_is_absolute_epoch_not_relative() {
276 let now = SystemTime::now()
277 .duration_since(UNIX_EPOCH)
278 .unwrap()
279 .as_secs();
280 let absolute_expiry = now + 900; let mut mocks = MockSet::new();
283 mocks.mock(move |when, then| {
284 when.post().path("/api/authorise");
285 then.json(serde_json::json!({
286 "accessToken": "tok",
287 "expiry": absolute_expiry
288 }));
289 });
290 let server = start_server(mocks).await;
291
292 let (_calls, provider) = counting_provider();
293 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
294 let token = refresher.refresh(&()).await.unwrap();
295
296 assert!(
297 token.expires_in() <= 1000,
298 "expires_in should be ~900s (absolute `expiry` used as-is); got {} \
299 — pre-fix `now + expiry` yields ~1.7e9",
300 token.expires_in()
301 );
302 assert!(
303 !token.is_expired(),
304 "a freshly federated 15-minute token must not be reported as already expired"
305 );
306 }
307
308 #[tokio::test]
309 async fn test_initial_federation() {
310 let mut mocks = MockSet::new();
311 mocks.mock(|when, then| {
312 when.post().path("/api/authorise");
313 then.json(auth_response_json("cts-token", 3600));
314 });
315 let server = start_server(mocks).await;
316 let (calls, provider) = counting_provider();
317 let strategy = make_strategy(&server, provider);
318
319 let token = strategy.get_token().await.unwrap();
320
321 assert_eq!(token.as_str(), "cts-token");
322 assert_eq!(
323 calls.load(Ordering::SeqCst),
324 1,
325 "initial federation should invoke the OIDC provider once"
326 );
327 }
328
329 #[test]
330 fn test_request_serialization() {
331 let body = serde_json::to_value(OidcAuthoriseRequest {
332 oidc_token: "the-jwt",
333 workspace_id: WORKSPACE_ID,
334 })
335 .unwrap();
336 assert_eq!(
337 body,
338 serde_json::json!({ "oidcToken": "the-jwt", "workspaceId": WORKSPACE_ID }),
339 "request body should carry exactly the OIDC token and workspace ID"
340 );
341 }
342
343 #[tokio::test]
344 async fn test_caches_token_after_initial_federation() {
345 let mut mocks = MockSet::new();
346 mocks.mock(|when, then| {
347 when.post().path("/api/authorise");
348 then.json(auth_response_json("cts-token", 3600));
349 });
350 let server = start_server(mocks).await;
351 let (calls, provider) = counting_provider();
352 let strategy = make_strategy(&server, provider);
353
354 assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token");
355
356 server.mocks().clear();
358 server.mocks().mock(|when, then| {
359 when.post().path("/api/authorise");
360 then.internal_server_error()
361 .json(serde_json::json!({"error": "should not be called"}));
362 });
363
364 assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token");
365 assert_eq!(
366 calls.load(Ordering::SeqCst),
367 1,
368 "cached token should be returned without re-federating"
369 );
370 }
371
372 #[tokio::test]
373 async fn test_re_federates_on_expiry() {
374 let mut mocks = MockSet::new();
375 mocks.mock(|when, then| {
376 when.post().path("/api/authorise");
377 then.json(auth_response_json("re-federated-token", 3600));
378 });
379 let server = start_server(mocks).await;
380
381 let (calls, provider) = counting_provider();
382 let store = Arc::new(crate::InMemoryTokenStore::new());
384 store.save(&make_token("stale-cts-token", 0)).await;
385
386 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
387 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
388
389 let token = strategy.get_token().await.unwrap();
390 assert_eq!(
391 token.as_str(),
392 "re-federated-token",
393 "expired cached token should trigger re-federation"
394 );
395 assert_eq!(
396 calls.load(Ordering::SeqCst),
397 1,
398 "re-federation should invoke the OIDC provider for a current JWT"
399 );
400 }
401
402 #[tokio::test]
403 async fn test_oidc_provider_failure_propagates() {
404 let mut mocks = MockSet::new();
405 mocks.mock(|when, then| {
406 when.post().path("/api/authorise");
407 then.json(auth_response_json("unreachable", 3600));
408 });
409 let server = start_server(mocks).await;
410
411 let provider = OidcProviderFn::new(|| async {
412 Err::<SecretToken, _>(AuthError::Server(crate::error::ServerError(
413 "provider exploded".to_string(),
414 )))
415 });
416 let strategy = make_strategy(&server, provider);
417
418 let err = strategy.get_token().await.unwrap_err();
419 assert!(
420 matches!(err, AutoRefreshError::Auth(AuthError::Server(_))),
421 "OIDC provider failure should surface as an auth error, got: {err:?}"
422 );
423 }
424
425 #[tokio::test]
426 async fn test_server_rejection_propagates() {
427 let mut mocks = MockSet::new();
428 mocks.mock(|when, then| {
429 when.post().path("/api/authorise");
430 then.internal_server_error()
431 .json(serde_json::json!({"error": "workspace mismatch"}));
432 });
433 let server = start_server(mocks).await;
434 let (_calls, provider) = counting_provider();
435 let strategy = make_strategy(&server, provider);
436
437 let err = strategy.get_token().await.unwrap_err();
438 assert!(
439 matches!(err, AutoRefreshError::Auth(AuthError::Server(_))),
440 "a 500 from /api/authorise should surface as a server error, got: {err:?}"
441 );
442 }
443
444 #[tokio::test]
445 async fn test_loads_token_from_store_on_cold_start_no_http() {
446 let mut mocks = MockSet::new();
447 mocks.mock(|when, then| {
448 when.post().path("/api/authorise");
449 then.internal_server_error()
450 .json(serde_json::json!({"error": "should not be called"}));
451 });
452 let server = start_server(mocks).await;
453
454 let store = Arc::new(crate::InMemoryTokenStore::new());
455 store.save(&make_token("from-store", 3600)).await;
456
457 let (calls, provider) = counting_provider();
458 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
459 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
460
461 let token = strategy.get_token().await.unwrap();
462 assert_eq!(token.as_str(), "from-store");
463 assert_eq!(
464 calls.load(Ordering::SeqCst),
465 0,
466 "a fresh cached token should be used without invoking the OIDC provider"
467 );
468 }
469
470 #[tokio::test]
471 async fn test_persists_token_to_store_after_federation() {
472 let mut mocks = MockSet::new();
473 mocks.mock(|when, then| {
474 when.post().path("/api/authorise");
475 then.json(auth_response_json("freshly-federated", 3600));
476 });
477 let server = start_server(mocks).await;
478
479 let store = Arc::new(crate::InMemoryTokenStore::new());
480 let (_calls, provider) = counting_provider();
481 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
482 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
483
484 let token = strategy.get_token().await.unwrap();
485 assert_eq!(token.as_str(), "freshly-federated");
486
487 let saved = store
488 .load()
489 .await
490 .expect("store should hold a token after federation");
491 assert_eq!(saved.access_token().as_str(), "freshly-federated");
492 }
493}