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(format!("{status}: {body}")));
166 }
167
168 let auth_resp: AuthoriseResponse = resp.json().await?;
169
170 Ok(auth_resp.into())
173 }
174}
175
176#[derive(serde::Serialize)]
177#[serde(rename_all = "camelCase")]
178struct OidcAuthoriseRequest<'a> {
179 oidc_token: &'a str,
180 workspace_id: &'a str,
181}
182
183#[cfg(test)]
184#[allow(clippy::unwrap_used)]
185mod tests {
186 use std::sync::atomic::{AtomicUsize, Ordering};
187 use std::sync::Arc;
188 use std::time::{SystemTime, UNIX_EPOCH};
189
190 use mocktail::prelude::*;
191
192 use super::*;
193 use crate::auto_refresh::{AutoRefresh, AutoRefreshError};
194 use crate::TokenStore;
195
196 const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY";
197
198 fn workspace_id() -> WorkspaceId {
199 WORKSPACE_ID.parse().unwrap()
200 }
201
202 fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value {
206 let now = SystemTime::now()
207 .duration_since(UNIX_EPOCH)
208 .unwrap()
209 .as_secs();
210 serde_json::json!({
211 "accessToken": access,
212 "expiry": now + expires_in_secs
213 })
214 }
215
216 async fn start_server(mocks: MockSet) -> MockServer {
217 let server = MockServer::new_http("oidc-refresher-test").with_mocks(mocks);
218 server.start().await.unwrap();
219 server
220 }
221
222 fn counting_provider() -> (Arc<AtomicUsize>, impl OidcProvider) {
225 let calls = Arc::new(AtomicUsize::new(0));
226 let calls_clone = Arc::clone(&calls);
227 let provider = OidcProviderFn::new(move || {
228 let calls = Arc::clone(&calls_clone);
229 async move {
230 let n = calls.fetch_add(1, Ordering::SeqCst);
231 Ok(SecretToken::new(format!("jwt-{n}")))
232 }
233 });
234 (calls, provider)
235 }
236
237 fn make_strategy<P: OidcProvider>(
238 server: &MockServer,
239 provider: P,
240 ) -> AutoRefresh<OidcRefresher<P>> {
241 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
242 AutoRefresh::with_store(refresher, crate::NoStore)
243 }
244
245 fn make_token(access: &str, expires_in_secs: u64) -> Token {
246 let now = SystemTime::now()
247 .duration_since(UNIX_EPOCH)
248 .unwrap()
249 .as_secs();
250 Token {
251 access_token: SecretToken::new(access),
252 token_type: "Bearer".to_string(),
253 expires_at: now + expires_in_secs,
254 refresh_token: None,
255 region: None,
256 client_id: None,
257 device_instance_id: None,
258 }
259 }
260
261 #[tokio::test]
273 async fn oidc_expiry_is_absolute_epoch_not_relative() {
274 let now = SystemTime::now()
275 .duration_since(UNIX_EPOCH)
276 .unwrap()
277 .as_secs();
278 let absolute_expiry = now + 900; let mut mocks = MockSet::new();
281 mocks.mock(move |when, then| {
282 when.post().path("/api/authorise");
283 then.json(serde_json::json!({
284 "accessToken": "tok",
285 "expiry": absolute_expiry
286 }));
287 });
288 let server = start_server(mocks).await;
289
290 let (_calls, provider) = counting_provider();
291 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
292 let token = refresher.refresh(&()).await.unwrap();
293
294 assert!(
295 token.expires_in() <= 1000,
296 "expires_in should be ~900s (absolute `expiry` used as-is); got {} \
297 — pre-fix `now + expiry` yields ~1.7e9",
298 token.expires_in()
299 );
300 assert!(
301 !token.is_expired(),
302 "a freshly federated 15-minute token must not be reported as already expired"
303 );
304 }
305
306 #[tokio::test]
307 async fn test_initial_federation() {
308 let mut mocks = MockSet::new();
309 mocks.mock(|when, then| {
310 when.post().path("/api/authorise");
311 then.json(auth_response_json("cts-token", 3600));
312 });
313 let server = start_server(mocks).await;
314 let (calls, provider) = counting_provider();
315 let strategy = make_strategy(&server, provider);
316
317 let token = strategy.get_token().await.unwrap();
318
319 assert_eq!(token.as_str(), "cts-token");
320 assert_eq!(
321 calls.load(Ordering::SeqCst),
322 1,
323 "initial federation should invoke the OIDC provider once"
324 );
325 }
326
327 #[test]
328 fn test_request_serialization() {
329 let body = serde_json::to_value(OidcAuthoriseRequest {
330 oidc_token: "the-jwt",
331 workspace_id: WORKSPACE_ID,
332 })
333 .unwrap();
334 assert_eq!(
335 body,
336 serde_json::json!({ "oidcToken": "the-jwt", "workspaceId": WORKSPACE_ID }),
337 "request body should carry exactly the OIDC token and workspace ID"
338 );
339 }
340
341 #[tokio::test]
342 async fn test_caches_token_after_initial_federation() {
343 let mut mocks = MockSet::new();
344 mocks.mock(|when, then| {
345 when.post().path("/api/authorise");
346 then.json(auth_response_json("cts-token", 3600));
347 });
348 let server = start_server(mocks).await;
349 let (calls, provider) = counting_provider();
350 let strategy = make_strategy(&server, provider);
351
352 assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token");
353
354 server.mocks().clear();
356 server.mocks().mock(|when, then| {
357 when.post().path("/api/authorise");
358 then.internal_server_error()
359 .json(serde_json::json!({"error": "should not be called"}));
360 });
361
362 assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token");
363 assert_eq!(
364 calls.load(Ordering::SeqCst),
365 1,
366 "cached token should be returned without re-federating"
367 );
368 }
369
370 #[tokio::test]
371 async fn test_re_federates_on_expiry() {
372 let mut mocks = MockSet::new();
373 mocks.mock(|when, then| {
374 when.post().path("/api/authorise");
375 then.json(auth_response_json("re-federated-token", 3600));
376 });
377 let server = start_server(mocks).await;
378
379 let (calls, provider) = counting_provider();
380 let store = Arc::new(crate::InMemoryTokenStore::new());
382 store.save(&make_token("stale-cts-token", 0)).await;
383
384 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
385 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
386
387 let token = strategy.get_token().await.unwrap();
388 assert_eq!(
389 token.as_str(),
390 "re-federated-token",
391 "expired cached token should trigger re-federation"
392 );
393 assert_eq!(
394 calls.load(Ordering::SeqCst),
395 1,
396 "re-federation should invoke the OIDC provider for a current JWT"
397 );
398 }
399
400 #[tokio::test]
401 async fn test_oidc_provider_failure_propagates() {
402 let mut mocks = MockSet::new();
403 mocks.mock(|when, then| {
404 when.post().path("/api/authorise");
405 then.json(auth_response_json("unreachable", 3600));
406 });
407 let server = start_server(mocks).await;
408
409 let provider = OidcProviderFn::new(|| async {
410 Err::<SecretToken, _>(AuthError::Server("provider exploded".to_string()))
411 });
412 let strategy = make_strategy(&server, provider);
413
414 let err = strategy.get_token().await.unwrap_err();
415 assert!(
416 matches!(err, AutoRefreshError::Auth(AuthError::Server(_))),
417 "OIDC provider failure should surface as an auth error, got: {err:?}"
418 );
419 }
420
421 #[tokio::test]
422 async fn test_server_rejection_propagates() {
423 let mut mocks = MockSet::new();
424 mocks.mock(|when, then| {
425 when.post().path("/api/authorise");
426 then.internal_server_error()
427 .json(serde_json::json!({"error": "workspace mismatch"}));
428 });
429 let server = start_server(mocks).await;
430 let (_calls, provider) = counting_provider();
431 let strategy = make_strategy(&server, provider);
432
433 let err = strategy.get_token().await.unwrap_err();
434 assert!(
435 matches!(err, AutoRefreshError::Auth(AuthError::Server(_))),
436 "a 500 from /api/authorise should surface as a server error, got: {err:?}"
437 );
438 }
439
440 #[tokio::test]
441 async fn test_loads_token_from_store_on_cold_start_no_http() {
442 let mut mocks = MockSet::new();
443 mocks.mock(|when, then| {
444 when.post().path("/api/authorise");
445 then.internal_server_error()
446 .json(serde_json::json!({"error": "should not be called"}));
447 });
448 let server = start_server(mocks).await;
449
450 let store = Arc::new(crate::InMemoryTokenStore::new());
451 store.save(&make_token("from-store", 3600)).await;
452
453 let (calls, provider) = counting_provider();
454 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
455 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
456
457 let token = strategy.get_token().await.unwrap();
458 assert_eq!(token.as_str(), "from-store");
459 assert_eq!(
460 calls.load(Ordering::SeqCst),
461 0,
462 "a fresh cached token should be used without invoking the OIDC provider"
463 );
464 }
465
466 #[tokio::test]
467 async fn test_persists_token_to_store_after_federation() {
468 let mut mocks = MockSet::new();
469 mocks.mock(|when, then| {
470 when.post().path("/api/authorise");
471 then.json(auth_response_json("freshly-federated", 3600));
472 });
473 let server = start_server(mocks).await;
474
475 let store = Arc::new(crate::InMemoryTokenStore::new());
476 let (_calls, provider) = counting_provider();
477 let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""));
478 let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store));
479
480 let token = strategy.get_token().await.unwrap();
481 assert_eq!(token.as_str(), "freshly-federated");
482
483 let saved = store
484 .load()
485 .await
486 .expect("store should hold a token after federation");
487 assert_eq!(saved.access_token().as_str(), "freshly-federated");
488 }
489}