Skip to main content

stack_auth/
access_key_strategy.rs

1use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId};
2
3use crate::access_key::AccessKey;
4use crate::access_key_refresher::AccessKeyRefresher;
5use crate::auto_refresh::AutoRefresh;
6use crate::token_store::{NoStore, TokenStore};
7use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken};
8
9/// An [`AuthStrategy`] that uses a static access key to authenticate against
10/// a specific workspace.
11///
12/// The strategy is bound to a workspace CRN at construction. The region is
13/// derived from the CRN — there is no separate `region` argument — so a
14/// caller can't accidentally point the strategy at one region while the
15/// CRN says another.
16///
17/// The first call to [`get_token`](AuthStrategy::get_token) authenticates
18/// with the server. Subsequent calls return the cached token until it
19/// expires, at which point re-authentication happens automatically. Every
20/// returned token is checked against the CRN; post-auth verification can
21/// fail in two ways:
22///
23/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its
24///   `workspace` claim doesn't match the CRN's workspace ID.
25/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the
26///   `workspace` claim entirely, so verification can't run.
27///
28/// Either outcome is preferred over silently letting the caller operate on
29/// a different workspace than they specified.
30///
31/// When constructed via [`AccessKeyStrategyBuilder::with_token_store`], the
32/// strategy also persists tokens through an external [`TokenStore`] so that
33/// short-lived strategy instances (e.g. one per Edge Function request) can
34/// share a cache and avoid re-authenticating every cold start.
35///
36/// # Example
37///
38/// ```no_run
39/// use stack_auth::{AccessKey, AccessKeyStrategy};
40/// use cts_common::Crn;
41///
42/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap();
43/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap();
44/// let strategy = AccessKeyStrategy::new(crn, key).unwrap();
45/// ```
46pub struct AccessKeyStrategy<S = NoStore> {
47    inner: AutoRefresh<AccessKeyRefresher, S>,
48    expected_workspace: WorkspaceId,
49}
50
51impl AccessKeyStrategy {
52    /// Create a new `AccessKeyStrategy` for the given workspace CRN and
53    /// access key. The auth endpoint is resolved automatically via service
54    /// discovery using the region encoded in the CRN.
55    ///
56    /// The `CS_CTS_HOST` environment variable, if set and non-empty,
57    /// overrides service discovery — useful for pointing the strategy at
58    /// a staging CTS or a local mock without changing the CRN.
59    ///
60    /// A CRN with a `service_name` component (e.g.
61    /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the
62    /// `service_name` is ignored. Only the region and workspace ID are
63    /// load-bearing for this strategy.
64    pub fn new(workspace_crn: Crn, access_key: AccessKey) -> Result<Self, AuthError> {
65        Self::builder(workspace_crn, access_key).build()
66    }
67
68    /// Return a builder for configuring an `AccessKeyStrategy` before construction.
69    ///
70    /// # Example
71    ///
72    /// ```no_run
73    /// use stack_auth::{AccessKey, AccessKeyStrategy};
74    /// use cts_common::Crn;
75    ///
76    /// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap();
77    /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap();
78    /// let strategy = AccessKeyStrategy::builder(crn, key)
79    ///     .audience("my-audience")
80    ///     .build()
81    ///     .unwrap();
82    /// ```
83    pub fn builder(workspace_crn: Crn, access_key: AccessKey) -> AccessKeyStrategyBuilder {
84        AccessKeyStrategyBuilder {
85            workspace_crn,
86            access_key: access_key.into_secret_token(),
87            audience: None,
88            base_url_override: None,
89            token_store: NoStore,
90        }
91    }
92}
93
94impl<S: TokenStore> AuthStrategy for &AccessKeyStrategy<S> {
95    async fn get_token(self) -> Result<ServiceToken, AuthError> {
96        self.inner
97            .get_token()
98            .await?
99            .verify_workspace(self.expected_workspace)
100    }
101}
102
103/// Builder for [`AccessKeyStrategy`].
104///
105/// Created via [`AccessKeyStrategy::builder`].
106pub struct AccessKeyStrategyBuilder<S = NoStore> {
107    workspace_crn: Crn,
108    access_key: SecretToken,
109    audience: Option<String>,
110    base_url_override: Option<url::Url>,
111    token_store: S,
112}
113
114impl<S> AccessKeyStrategyBuilder<S> {
115    /// Set the audience for token requests.
116    pub fn audience(mut self, audience: impl Into<String>) -> Self {
117        self.audience = Some(audience.into());
118        self
119    }
120
121    /// Override the CTS base URL resolved for this strategy.
122    ///
123    /// Takes precedence over both the `CS_CTS_HOST` environment variable and
124    /// region-derived service discovery. Use it to point a single strategy
125    /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local
126    /// mock auth server in development — without relying on the process-wide
127    /// `CS_CTS_HOST`, which would redirect every other CTS client sharing the
128    /// process.
129    pub fn base_url(mut self, url: url::Url) -> Self {
130        self.base_url_override = Some(url);
131        self
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-authenticating with the access key. After every successful refresh
139    /// or initial auth, the new token is written back to the store. Use this
140    /// from short-lived strategy instances (Edge Functions, Workers, proxy
141    /// worker pools) to share a service-token cache across processes.
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
146    /// implementations.
147    pub fn with_token_store<T: TokenStore>(self, store: T) -> AccessKeyStrategyBuilder<T> {
148        AccessKeyStrategyBuilder {
149            workspace_crn: self.workspace_crn,
150            access_key: self.access_key,
151            audience: self.audience,
152            base_url_override: self.base_url_override,
153            token_store: store,
154        }
155    }
156}
157
158impl<S: TokenStore> AccessKeyStrategyBuilder<S> {
159    /// Build the [`AccessKeyStrategy`].
160    ///
161    /// Resolves the base URL in priority order: an explicit [`base_url`]
162    /// override, then the `CS_CTS_HOST` environment variable, then service
163    /// discovery using the CRN's region.
164    ///
165    /// [`base_url`]: Self::base_url
166    pub fn build(self) -> Result<AccessKeyStrategy<S>, AuthError> {
167        let expected_workspace = self.workspace_crn.workspace_id;
168        let region = self.workspace_crn.region;
169        let base_url = match self.base_url_override {
170            Some(url) => url,
171            None => {
172                crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?)
173            }
174        };
175        let refresher = AccessKeyRefresher::new(
176            self.access_key,
177            ensure_trailing_slash(base_url),
178            self.audience,
179        );
180        Ok(AccessKeyStrategy {
181            inner: AutoRefresh::with_store(refresher, self.token_store),
182            expected_workspace,
183        })
184    }
185}
186
187#[cfg(test)]
188mod workspace_verification_tests {
189    use super::*;
190    use crate::test_support::{crn_with_workspace, jwt_with_workspace};
191    use mocktail::prelude::*;
192    use std::time::UNIX_EPOCH;
193
194    async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer {
195        let mut mocks = MockSet::new();
196        let jwt = jwt_with_workspace(workspace);
197        mocks.mock(move |when, then| {
198            when.post().path("/api/authorise");
199            then.json(serde_json::json!({
200                "accessToken": jwt,
201                "expiry": 3600,
202            }));
203        });
204        let server = MockServer::new_http("access-key-strategy-workspace-test").with_mocks(mocks);
205        #[allow(clippy::expect_used)]
206        server.start().await.expect("mock server start");
207        server
208    }
209
210    fn test_access_key() -> AccessKey {
211        "CSAKtestKeyId.testKeySecret"
212            .parse()
213            .expect("test access key parses")
214    }
215
216    /// Happy path — JWT workspace matches the CRN: `get_token()` returns
217    /// the token cleanly.
218    #[tokio::test]
219    async fn returns_token_when_workspace_matches() {
220        const WS: &str = "ZVATKW3VHMFG27DY";
221        let server = start_mock_server_returning_jwt(WS).await;
222        let crn = crn_with_workspace(WS);
223
224        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
225            .base_url(server.url(""))
226            .build()
227            .expect("builder");
228
229        let token = (&strategy).get_token().await.expect("get_token");
230        assert_eq!(
231            token.workspace_id().expect("workspace_id").as_str(),
232            WS,
233            "happy-path token should carry the expected workspace",
234        );
235    }
236
237    /// Mismatch — JWT workspace differs from the CRN's: `get_token()`
238    /// returns `AuthError::WorkspaceMismatch` rather than the token.
239    #[tokio::test]
240    async fn errors_when_token_workspace_differs_from_crn() {
241        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
242        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
243        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
244        let crn = crn_with_workspace(CRN_WS);
245
246        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
247            .base_url(server.url(""))
248            .build()
249            .expect("builder");
250
251        let err = (&strategy)
252            .get_token()
253            .await
254            .expect_err("expected mismatch");
255        match err {
256            AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
257                expected_workspace,
258                token_workspace,
259            }) => {
260                assert_eq!(expected_workspace.as_str(), CRN_WS);
261                assert_eq!(token_workspace.as_str(), TOKEN_WS);
262            }
263            other => panic!("expected WorkspaceMismatch, got {other:?}"),
264        }
265        assert_eq!(
266            AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
267                expected_workspace: CRN_WS.parse().unwrap(),
268                token_workspace: TOKEN_WS.parse().unwrap(),
269            })
270            .error_code(),
271            "WORKSPACE_MISMATCH",
272        );
273    }
274
275    /// A CRN carrying a `service_name` component is accepted; the
276    /// `service_name` is ignored. The strategy uses only the region (for
277    /// service discovery) and the workspace ID (for token verification).
278    /// Pinned as a test rather than left to implementation drift so that a
279    /// future contributor doesn't tighten the constructor into rejecting
280    /// these CRNs without realising the docstring already promises
281    /// acceptance.
282    #[tokio::test]
283    async fn accepts_crn_with_service_name() {
284        const WS: &str = "ZVATKW3VHMFG27DY";
285        let server = start_mock_server_returning_jwt(WS).await;
286        let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms")
287            .parse()
288            .expect("CRN with service_name parses");
289
290        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
291            .base_url(server.url(""))
292            .build()
293            .expect("CRN with service_name should construct a strategy");
294
295        let token = (&strategy).get_token().await.expect("get_token");
296        assert_eq!(
297            token.workspace_id().expect("workspace_id").as_str(),
298            WS,
299            "service_name is ignored — verification still uses the workspace ID",
300        );
301    }
302
303    /// A pre-populated [`TokenStore`] returning a token for a *different*
304    /// workspace must still be rejected by the strategy's wrapper. This
305    /// is the cross-feature interaction the CRN parity work is designed
306    /// to protect — a shared cookie / KV cache between strategies bound
307    /// to different workspaces must never let a load from the store
308    /// bypass workspace verification.
309    ///
310    /// Drives the assertion without any HTTP traffic: a 500-returning
311    /// mock fails the test loudly if the strategy ever reaches the
312    /// authorise endpoint instead of trusting the store.
313    #[tokio::test]
314    async fn rejects_stored_token_for_different_workspace() {
315        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
316        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
317
318        let mut mocks = MockSet::new();
319        mocks.mock(|when, then| {
320            when.post().path("/api/authorise");
321            then.internal_server_error()
322                .json(serde_json::json!({"error": "store must satisfy the request"}));
323        });
324        let server =
325            MockServer::new_http("access-key-strategy-store-mismatch-test").with_mocks(mocks);
326        #[allow(clippy::expect_used)]
327        server.start().await.expect("mock server start");
328
329        let now = std::time::SystemTime::now()
330            .duration_since(UNIX_EPOCH)
331            .expect("system clock")
332            .as_secs();
333        let stored = crate::Token {
334            access_token: crate::SecretToken::new(jwt_with_workspace(TOKEN_WS)),
335            token_type: "Bearer".to_string(),
336            expires_at: now + 3600,
337            refresh_token: None,
338            region: None,
339            client_id: None,
340            device_instance_id: None,
341        };
342        let store = std::sync::Arc::new(crate::InMemoryTokenStore::new());
343        store.save(&stored).await;
344
345        let strategy = AccessKeyStrategy::builder(crn_with_workspace(CRN_WS), test_access_key())
346            .base_url(server.url(""))
347            .with_token_store(std::sync::Arc::clone(&store))
348            .build()
349            .expect("builder");
350
351        let err = (&strategy)
352            .get_token()
353            .await
354            .expect_err("expected mismatch from stored token");
355        assert!(
356            matches!(err, AuthError::WorkspaceMismatch { .. }),
357            "expected WorkspaceMismatch, got {err:?}",
358        );
359    }
360
361    /// Regression guard — the workspace check runs on *every* `get_token()`
362    /// call, not only on the call that triggers initial authentication.
363    /// A future optimisation that cached the "verified" result, or that
364    /// stashed the token into a field bypassing the wrapper, would let a
365    /// mismatched token slide through on the second call. Verified by
366    /// calling `get_token()` twice against the same mock and asserting
367    /// both fail with `WorkspaceMismatch`.
368    #[tokio::test]
369    async fn errors_on_each_subsequent_get_token_call() {
370        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
371        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
372        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
373        let crn = crn_with_workspace(CRN_WS);
374
375        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
376            .base_url(server.url(""))
377            .build()
378            .expect("builder");
379
380        for call in 1..=2 {
381            let result = (&strategy).get_token().await;
382            let err = match result {
383                Ok(_) => panic!("call {call}: expected Err, got Ok"),
384                Err(e) => e,
385            };
386            assert!(
387                matches!(err, AuthError::WorkspaceMismatch { .. }),
388                "call {call}: expected WorkspaceMismatch, got {err:?}",
389            );
390        }
391    }
392}