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(format!("{status}: {body}")));
166        }
167
168        let auth_resp: AuthoriseResponse = resp.json().await?;
169
170        // The response → Token mapping (including the absolute-epoch `expiry`
171        // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`.
172        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    /// Build a mock `/api/authorise` response. CTS returns `expiry` as an
203    /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the
204    /// token is valid for `expires_in_secs` from now.
205    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    /// A [`OidcProvider`] test double that counts invocations and returns a
223    /// distinct JWT each call (`jwt-0`, `jwt-1`, …).
224    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    // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ----
262
263    /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT
264    /// `exp` claim), not a relative duration — identical to the access-key path
265    /// fixed in CIP-3233. The OIDC refresher must use it as-is.
266    ///
267    /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands
268    /// ~decades in the future, so `is_expired()` is never true — the federated
269    /// token never re-federates and silently dies at its real ~15-minute `exp`.
270    /// The assertion below fails under the pre-fix arithmetic (`expires_in()` ≈
271    /// 1.7e9) and passes with the fix (`expires_in()` ≈ 900). See CIP-3233.
272    #[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; // a 15-minute token, as an absolute epoch
279
280        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        // Replace the mock so a second federation call would fail loudly.
355        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        // Pre-seed the store with an already-expired token.
381        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}