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 {
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}