Skip to main content

stack_auth/
oidc_refresher.rs

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/// Asynchronously supplies the *current* third-party OIDC JWT to federate.
12///
13/// [`OidcFederationStrategy`](crate::OidcFederationStrategy) re-invokes this on every refresh:
14/// `/api/authorise` issues no CTS refresh token, so renewing an expired CTS
15/// token means re-federating with a fresh provider JWT. Implementations
16/// typically wrap a provider SDK call (`clerk.session.getToken()`,
17/// `supabase.auth.getSession()`), an FFI callback, or a test double.
18///
19/// On native targets the trait carries `Send + Sync` bounds so the provider
20/// can be driven from `tokio::spawn` background work. On wasm32 the bounds
21/// are dropped — reqwest's fetch-backed futures are not `Send` and edge
22/// runtimes are single-threaded anyway.
23#[cfg(not(target_arch = "wasm32"))]
24pub trait OidcProvider: Send + Sync {
25    /// Fetch the current third-party OIDC JWT to federate.
26    fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> + Send;
27}
28
29/// Wasm32 variant of [`OidcProvider`] — drops the `Send + Sync` bounds.
30#[cfg(target_arch = "wasm32")]
31pub trait OidcProvider {
32    /// Fetch the current third-party OIDC JWT to federate.
33    fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>>;
34}
35
36/// [`OidcProvider`] backed by a user-supplied async closure.
37///
38/// The closure fires on every federation — initial auth and every
39/// re-federation after expiry — so it must return the *current* JWT each
40/// time, not a value captured once. The point of the closure is to defer to
41/// the provider's own session machinery on every call: a provider SDK
42/// (`clerk.session.getToken()`, `supabase.auth.getSession()`) hands back a
43/// freshly-minted short-lived JWT, transparently refreshing its own session
44/// as needed. Capturing a single token up front would instead pin a JWT that
45/// expires and can never be renewed.
46///
47/// # Example
48///
49/// ```no_run
50/// use stack_auth::{AuthError, OidcProviderFn, SecretToken};
51///
52/// # async fn clerk_session_get_token() -> Result<String, AuthError> { Ok(String::new()) }
53/// // Each call asks the provider SDK for the *current* session token, so an
54/// // expired JWT is refreshed upstream rather than reused.
55/// let provider = OidcProviderFn::new(|| async {
56///     let jwt = clerk_session_get_token().await?;
57///     Ok::<_, AuthError>(SecretToken::new(jwt))
58/// });
59/// ```
60pub struct OidcProviderFn<F> {
61    fetch: F,
62}
63
64impl<F> OidcProviderFn<F> {
65    /// Build an `OidcProviderFn` from an async closure returning the current JWT.
66    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
93/// A [`Refresher`] that federates a third-party OIDC JWT into a CTS service
94/// token via `POST /api/authorise`.
95///
96/// *Our* federation step is stateless: the credential is always available (the
97/// [`OidcProvider`] is re-callable), so `try_credential` returns `Some(())` and
98/// `restore` is a no-op — exactly like
99/// [`AccessKeyRefresher`](crate::access_key_refresher). The *upstream* OIDC
100/// provider that issues the JWT is typically not stateless — it usually relies
101/// on its own session machinery (cookies, a session store, a refresh token)
102/// to mint the short-lived JWT that [`OidcProvider::fetch`] returns.
103///
104/// `/api/authorise` issues no CTS refresh token. Keeping the federated CTS
105/// token fresh is therefore the upstream caller's responsibility, via whatever
106/// mechanism the provider requires: when the CTS token expires, `AutoRefresh`
107/// renews it by calling `refresh` again, which re-invokes the `OidcProvider`
108/// for a current JWT — so a provider that hands back an expired or stale JWT
109/// will produce an expired or stale CTS token in turn.
110pub(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        // Federated tokens are ephemeral — no per-refresher persistence.
133    }
134
135    fn try_credential(&self, _token: Option<&mut Token>) -> Option<Self::Credential> {
136        // The OIDC provider is always re-callable, so federation can always be
137        // attempted — including on cold start (initial auth).
138        Some(())
139    }
140
141    fn restore(&self, _token: &mut Token, _credential: Self::Credential) {
142        // Nothing to restore — the OIDC provider is re-callable.
143    }
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        // The response → Token mapping (including the absolute-epoch `expiry`
173        // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`.
174        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    /// Build a mock `/api/authorise` response. CTS returns `expiry` as an
205    /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the
206    /// token is valid for `expires_in_secs` from now.
207    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    /// A [`OidcProvider`] test double that counts invocations and returns a
225    /// distinct JWT each call (`jwt-0`, `jwt-1`, …).
226    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    // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ----
264
265    /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT
266    /// `exp` claim), not a relative duration — identical to the access-key path
267    /// fixed in CIP-3233. The OIDC refresher must use it as-is.
268    ///
269    /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands
270    /// ~decades in the future, so `is_expired()` is never true — the federated
271    /// token never re-federates and silently dies at its real ~15-minute `exp`.
272    /// The assertion below fails under the pre-fix arithmetic (`expires_in()` ≈
273    /// 1.7e9) and passes with the fix (`expires_in()` ≈ 900). See CIP-3233.
274    #[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; // a 15-minute token, as an absolute epoch
281
282        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        // Replace the mock so a second federation call would fail loudly.
357        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        // Pre-seed the store with an already-expired token.
383        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}