Skip to main content

stack_auth/
oidc_federation_strategy.rs

1use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId};
2
3use crate::auto_refresh::AutoRefresh;
4use crate::oidc_refresher::{OidcProvider, OidcRefresher};
5use crate::token_store::{NoStore, TokenStore};
6use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken};
7
8/// An [`AuthStrategy`] that federates a third-party OIDC JWT (Clerk, Supabase,
9/// Auth0, …) into a CipherStash CTS service token via `POST /api/authorise`.
10///
11/// Each call to [`get_token`](AuthStrategy::get_token) returns a cached CTS
12/// token until it expires. Because `/api/authorise` issues no CTS refresh
13/// token, renewal means *re-federating*: the strategy calls the
14/// [`OidcProvider`] again for a current third-party JWT and exchanges it for a
15/// fresh CTS token. Supply an `OidcProvider` that returns the live provider
16/// token each time (e.g. wrapping `clerk.session.getToken()`).
17///
18/// The strategy is bound to a workspace CRN at construction. The region is
19/// derived from the CRN — there is no separate `region` argument — so a
20/// caller can't accidentally point the strategy at one region while the
21/// CRN says another, matching
22/// [`AccessKeyStrategy`](crate::AccessKeyStrategy).
23///
24/// Every returned token is checked against the CRN's workspace — the
25/// same post-auth verification `AccessKeyStrategy` performs — so a token CTS
26/// minted for a different workspace (or one loaded from a poisoned shared
27/// cache) is never handed back. Verification can fail in two ways:
28///
29/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its
30///   `workspace` claim doesn't match the CRN's workspace ID.
31/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the
32///   `workspace` claim entirely, so verification can't run.
33///
34/// When constructed via [`OidcFederationStrategyBuilder::with_token_store`], the strategy
35/// also persists tokens through an external [`TokenStore`] so short-lived
36/// instances (e.g. one per Edge Function request) can share a cache and skip
37/// re-federating on every cold start. The workspace check runs on cached and
38/// store-loaded tokens too, not just freshly federated ones.
39///
40/// # Example
41///
42/// ```no_run
43/// use stack_auth::{AuthError, OidcProviderFn, OidcFederationStrategy, SecretToken};
44/// use cts_common::Crn;
45///
46/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap();
47/// let provider = OidcProviderFn::new(|| async {
48///     // Real consumers call into a provider SDK / FFI to fetch a live JWT.
49///     Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string()))
50/// });
51/// let strategy = OidcFederationStrategy::new(crn, provider).unwrap();
52/// ```
53pub struct OidcFederationStrategy<P, S = NoStore> {
54    inner: AutoRefresh<OidcRefresher<P>, S>,
55    expected_workspace: WorkspaceId,
56}
57
58impl<P: OidcProvider> OidcFederationStrategy<P> {
59    /// Create a new `OidcFederationStrategy` for the given workspace CRN and
60    /// OIDC provider.
61    ///
62    /// The auth endpoint is resolved automatically via service discovery
63    /// using the region encoded in the CRN; the workspace ID is used to
64    /// verify every federated token belongs to the right workspace.
65    ///
66    /// A CRN with a `service_name` component (e.g.
67    /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the
68    /// `service_name` is ignored. Only the region and workspace ID are
69    /// load-bearing for this strategy.
70    pub fn new(workspace_crn: Crn, oidc_provider: P) -> Result<Self, AuthError> {
71        Self::builder(workspace_crn, oidc_provider).build()
72    }
73
74    /// Return a builder for configuring an `OidcFederationStrategy` before construction.
75    pub fn builder(workspace_crn: Crn, oidc_provider: P) -> OidcFederationStrategyBuilder<P> {
76        OidcFederationStrategyBuilder {
77            workspace_crn,
78            oidc_provider,
79            base_url_override: None,
80            token_store: NoStore,
81        }
82    }
83}
84
85impl<P: OidcProvider, S: TokenStore> AuthStrategy for &OidcFederationStrategy<P, S> {
86    async fn get_token(self) -> Result<ServiceToken, AuthError> {
87        self.inner
88            .get_token()
89            .await?
90            .verify_workspace(self.expected_workspace)
91    }
92}
93
94/// Builder for [`OidcFederationStrategy`].
95///
96/// Created via [`OidcFederationStrategy::builder`].
97pub struct OidcFederationStrategyBuilder<P, S = NoStore> {
98    workspace_crn: Crn,
99    oidc_provider: P,
100    base_url_override: Option<url::Url>,
101    token_store: S,
102}
103
104impl<P, S> OidcFederationStrategyBuilder<P, S> {
105    /// Override the base URL resolved by service discovery.
106    ///
107    /// Takes precedence over both the `CS_CTS_HOST` environment variable and
108    /// region-derived service discovery. Use this to point a single strategy
109    /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local
110    /// mock auth server in development — without relying on the process-wide
111    /// `CS_CTS_HOST`, which would also redirect any other CTS client (e.g. the
112    /// `protect-ffi` encryption client) sharing the same process.
113    pub fn base_url(mut self, url: url::Url) -> Self {
114        self.base_url_override = Some(url);
115        self
116    }
117
118    /// Apply an optional base-URL override supplied as a raw string.
119    ///
120    /// The string-typed convenience the language bindings (napi, wasm) call,
121    /// so the "empty means absent, otherwise parse-or-reject" semantics live in
122    /// one place rather than being re-derived per binding. An absent or empty
123    /// string is a no-op — base-URL resolution falls back to `CS_CTS_HOST` /
124    /// region service discovery (see [`build`](Self::build)); a non-empty but
125    /// malformed string is rejected as [`AuthError::InvalidUrl`]. For an
126    /// already-parsed URL, use [`base_url`](Self::base_url).
127    pub fn maybe_base_url(self, base_url: Option<String>) -> Result<Self, AuthError> {
128        match base_url {
129            Some(s) if !s.is_empty() => Ok(self.base_url(s.parse::<url::Url>()?)),
130            _ => Ok(self),
131        }
132    }
133
134    /// Wire an external [`TokenStore`] into the strategy.
135    ///
136    /// On every call to [`get_token`](AuthStrategy::get_token), if no token is
137    /// cached in memory, the store is consulted before falling back to
138    /// re-federating. After every successful federation the new token is
139    /// written back to the store. Use this from short-lived strategy instances
140    /// (Edge Functions, Workers) to share a service-token cache across
141    /// processes — e.g. an HTTP-only cookie.
142    ///
143    /// Returns a new builder with the store type erased into the chain — see
144    /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and
145    /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made implementations.
146    pub fn with_token_store<T: TokenStore>(self, store: T) -> OidcFederationStrategyBuilder<P, T> {
147        OidcFederationStrategyBuilder {
148            workspace_crn: self.workspace_crn,
149            oidc_provider: self.oidc_provider,
150            base_url_override: self.base_url_override,
151            token_store: store,
152        }
153    }
154}
155
156impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> {
157    /// Build the [`OidcFederationStrategy`].
158    ///
159    /// Resolves the base URL in priority order: an explicit [`base_url`]
160    /// override, then the `CS_CTS_HOST` environment variable, then service
161    /// discovery using the CRN's region.
162    ///
163    /// [`base_url`]: Self::base_url
164    pub fn build(self) -> Result<OidcFederationStrategy<P, S>, AuthError> {
165        let expected_workspace = self.workspace_crn.workspace_id;
166        let region = self.workspace_crn.region;
167        let base_url = match self.base_url_override {
168            Some(url) => url,
169            None => {
170                crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?)
171            }
172        };
173        let refresher = OidcRefresher::new(
174            self.oidc_provider,
175            expected_workspace,
176            ensure_trailing_slash(base_url),
177        );
178        Ok(OidcFederationStrategy {
179            inner: AutoRefresh::with_store(refresher, self.token_store),
180            expected_workspace,
181        })
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
187mod tests {
188    use std::sync::Arc;
189    use std::time::{SystemTime, UNIX_EPOCH};
190
191    use mocktail::prelude::*;
192
193    use super::*;
194    use crate::oidc_refresher::OidcProviderFn;
195    use crate::test_support::{crn_with_workspace, jwt_with_workspace};
196    use crate::{InMemoryTokenStore, SecretToken, Token, TokenStore};
197
198    /// A mock CTS that federates any OIDC token into a CTS token carrying the
199    /// given `workspace` claim.
200    async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer {
201        let mut mocks = MockSet::new();
202        let jwt = jwt_with_workspace(workspace);
203        mocks.mock(move |when, then| {
204            when.post().path("/api/authorise");
205            then.json(serde_json::json!({ "accessToken": jwt, "expiry": 3600 }));
206        });
207        let server =
208            MockServer::new_http("oidc-federation-strategy-workspace-test").with_mocks(mocks);
209        server.start().await.expect("mock server start");
210        server
211    }
212
213    fn provider() -> OidcProviderFn<impl Fn() -> std::future::Ready<Result<SecretToken, AuthError>>>
214    {
215        OidcProviderFn::new(|| {
216            std::future::ready(Ok(SecretToken::new("header.payload.signature".to_string())))
217        })
218    }
219
220    const WS: &str = "ZVATKW3VHMFG27DY";
221
222    /// `maybe_base_url` is the string-typed override seam the language bindings
223    /// rely on; pin its empty/absent/valid/malformed semantics here so the napi
224    /// and wasm crates don't each re-test (and risk re-deriving) them.
225    mod maybe_base_url {
226        use super::*;
227
228        #[test]
229        fn absent_is_a_noop() {
230            let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
231                .maybe_base_url(None)
232                .unwrap();
233            assert!(b.base_url_override.is_none());
234        }
235
236        #[test]
237        fn empty_string_is_a_noop() {
238            let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
239                .maybe_base_url(Some(String::new()))
240                .unwrap();
241            assert!(b.base_url_override.is_none());
242        }
243
244        #[test]
245        fn valid_url_sets_the_override() {
246            let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
247                .maybe_base_url(Some("https://cts.example.com".to_string()))
248                .unwrap();
249            assert_eq!(
250                b.base_url_override.as_ref().map(url::Url::as_str),
251                Some("https://cts.example.com/")
252            );
253        }
254
255        #[test]
256        fn malformed_url_is_invalid_url() {
257            // The builder isn't `Debug`, so match on the result rather than
258            // `unwrap_err()` (which would require `T: Debug`).
259            match OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
260                .maybe_base_url(Some("not a url".to_string()))
261            {
262                Err(AuthError::InvalidUrl(_)) => {}
263                Ok(_) => panic!("expected Err(InvalidUrl), got Ok"),
264                Err(other) => panic!("expected InvalidUrl, got: {other:?}"),
265            }
266        }
267    }
268
269    /// Precedence: an explicit `base_url` override (the one `maybe_base_url`
270    /// sets) wins over the `CS_CTS_HOST` environment variable. `build()`
271    /// resolves the host in priority order override → `CS_CTS_HOST` →
272    /// discovery, so with `CS_CTS_HOST` pointed at a dead address the strategy
273    /// must still federate against the override's mock — proving the env var
274    /// was not consulted.
275    ///
276    /// `CS_CTS_HOST` is read inside `build()` (not `get_token`), so the env
277    /// override is scoped to just that synchronous call via `temp_env`; the
278    /// async federation runs with the environment already restored. No other
279    /// test in this crate reads `CS_CTS_HOST` (every strategy test pins
280    /// `base_url`), so this can't perturb a concurrent test.
281    #[tokio::test]
282    async fn base_url_override_takes_precedence_over_cs_cts_host() {
283        const WS: &str = "ZVATKW3VHMFG27DY";
284        let server = start_mock_server_returning_jwt(WS).await;
285
286        // A routable-but-dead host: if `CS_CTS_HOST` were consulted, federation
287        // would target this and fail rather than hitting the mock.
288        let strategy = temp_env::with_var("CS_CTS_HOST", Some("http://127.0.0.1:1/"), || {
289            OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
290                .maybe_base_url(Some(server.url("").to_string()))
291                .expect("override URL parses")
292                .build()
293                .expect("builder")
294        });
295
296        let token = (&strategy)
297            .get_token()
298            .await
299            .expect("override must win: federation should hit the mock, not CS_CTS_HOST");
300        assert_eq!(
301            token.workspace_id().expect("workspace_id").as_str(),
302            WS,
303            "token should come from the override's mock server",
304        );
305    }
306
307    /// Happy path — the federated token's `workspace` claim matches the
308    /// configured workspace: `get_token()` returns the token cleanly.
309    #[tokio::test]
310    async fn returns_token_when_workspace_matches() {
311        const WS: &str = "ZVATKW3VHMFG27DY";
312        let server = start_mock_server_returning_jwt(WS).await;
313
314        let strategy = OidcFederationStrategy::builder(crn_with_workspace(WS), provider())
315            .base_url(server.url(""))
316            .build()
317            .expect("builder");
318
319        let token = (&strategy).get_token().await.expect("get_token");
320        assert_eq!(
321            token.workspace_id().expect("workspace_id").as_str(),
322            WS,
323            "happy-path token should carry the expected workspace",
324        );
325    }
326
327    /// A CRN carrying a `service_name` component is accepted; the
328    /// `service_name` is ignored, exactly as for
329    /// [`AccessKeyStrategy`](crate::AccessKeyStrategy). Pinned as a test —
330    /// matching `access_key_strategy::accepts_crn_with_service_name` — so a
331    /// future contributor doesn't tighten the constructor into rejecting these
332    /// CRNs without realising the docstring already promises acceptance.
333    #[tokio::test]
334    async fn accepts_crn_with_service_name() {
335        const WS: &str = "ZVATKW3VHMFG27DY";
336        let server = start_mock_server_returning_jwt(WS).await;
337        let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms")
338            .parse()
339            .expect("CRN with service_name parses");
340
341        let strategy = OidcFederationStrategy::builder(crn, provider())
342            .base_url(server.url(""))
343            .build()
344            .expect("CRN with service_name should construct a strategy");
345
346        let token = (&strategy).get_token().await.expect("get_token");
347        assert_eq!(
348            token.workspace_id().expect("workspace_id").as_str(),
349            WS,
350            "service_name is ignored — verification still uses the workspace ID",
351        );
352    }
353
354    /// Mismatch — CTS federates the OIDC token into a CTS token for a
355    /// *different* workspace than the strategy was configured for. This is the
356    /// security-critical case: the OIDC provider could be authenticated for a
357    /// workspace the caller didn't intend. `get_token()` must return
358    /// `WorkspaceMismatch`, not the token.
359    #[tokio::test]
360    async fn errors_when_token_workspace_differs() {
361        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
362        const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
363        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
364
365        let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
366            .base_url(server.url(""))
367            .build()
368            .expect("builder");
369
370        let err = (&strategy)
371            .get_token()
372            .await
373            .expect_err("expected mismatch");
374        match err {
375            AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
376                expected_workspace,
377                token_workspace,
378            }) => {
379                assert_eq!(expected_workspace.as_str(), EXPECTED_WS);
380                assert_eq!(token_workspace.as_str(), TOKEN_WS);
381            }
382            other => panic!("expected WorkspaceMismatch, got {other:?}"),
383        }
384    }
385
386    /// A malformed CTS token (not a JWT) can't be decoded, so verification
387    /// can't run — `get_token()` surfaces `InvalidToken` rather than handing
388    /// back an unverifiable token.
389    #[tokio::test]
390    async fn errors_with_invalid_token_when_jwt_malformed() {
391        let mut mocks = MockSet::new();
392        mocks.mock(|when, then| {
393            when.post().path("/api/authorise");
394            then.json(serde_json::json!({ "accessToken": "not-a-jwt", "expiry": 3600 }));
395        });
396        let server =
397            MockServer::new_http("oidc-federation-strategy-malformed-test").with_mocks(mocks);
398        server.start().await.expect("mock server start");
399
400        let strategy =
401            OidcFederationStrategy::builder(crn_with_workspace("ZVATKW3VHMFG27DY"), provider())
402                .base_url(server.url(""))
403                .build()
404                .expect("builder");
405
406        let err = (&strategy)
407            .get_token()
408            .await
409            .expect_err("expected invalid-token error");
410        assert!(
411            matches!(err, AuthError::InvalidToken(_)),
412            "expected InvalidToken, got {err:?}",
413        );
414    }
415
416    /// A pre-populated [`TokenStore`] returning a token for a *different*
417    /// workspace must still be rejected by the strategy wrapper — the same
418    /// poisoned-shared-cache interaction `AccessKeyStrategy` guards against.
419    /// A 500-returning mock fails the test loudly if the strategy ever
420    /// re-federates instead of trusting (and rejecting) the stored token.
421    #[tokio::test]
422    async fn rejects_stored_token_for_different_workspace() {
423        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
424        const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
425
426        let mut mocks = MockSet::new();
427        mocks.mock(|when, then| {
428            when.post().path("/api/authorise");
429            then.internal_server_error()
430                .json(serde_json::json!({"error": "store must satisfy the request"}));
431        });
432        let server =
433            MockServer::new_http("oidc-federation-strategy-store-mismatch-test").with_mocks(mocks);
434        server.start().await.expect("mock server start");
435
436        let now = SystemTime::now()
437            .duration_since(UNIX_EPOCH)
438            .expect("system clock")
439            .as_secs();
440        let stored = Token {
441            access_token: SecretToken::new(jwt_with_workspace(TOKEN_WS)),
442            token_type: "Bearer".to_string(),
443            expires_at: now + 3600,
444            refresh_token: None,
445            region: None,
446            client_id: None,
447            device_instance_id: None,
448        };
449        let store = Arc::new(InMemoryTokenStore::new());
450        store.save(&stored).await;
451
452        let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
453            .base_url(server.url(""))
454            .with_token_store(Arc::clone(&store))
455            .build()
456            .expect("builder");
457
458        let err = (&strategy)
459            .get_token()
460            .await
461            .expect_err("expected mismatch from stored token");
462        assert!(
463            matches!(err, AuthError::WorkspaceMismatch { .. }),
464            "expected WorkspaceMismatch, got {err:?}",
465        );
466    }
467
468    /// Regression guard — the workspace check runs on *every* `get_token()`
469    /// call, not only the one that triggers initial federation. A future
470    /// optimisation that cached the "verified" verdict would let a mismatched
471    /// token slide through on the second call.
472    #[tokio::test]
473    async fn errors_on_each_subsequent_get_token_call() {
474        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
475        const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY";
476        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
477
478        let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider())
479            .base_url(server.url(""))
480            .build()
481            .expect("builder");
482
483        for call in 1..=2 {
484            let err = match (&strategy).get_token().await {
485                Ok(_) => panic!("call {call}: expected Err, got Ok"),
486                Err(e) => e,
487            };
488            assert!(
489                matches!(err, AuthError::WorkspaceMismatch { .. }),
490                "call {call}: expected WorkspaceMismatch, got {err:?}",
491            );
492        }
493    }
494}