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        let token: ServiceToken = self.inner.get_token().await?;
97        let token_workspace = *token.workspace_id()?;
98        if token_workspace != self.expected_workspace {
99            return Err(AuthError::WorkspaceMismatch {
100                expected_workspace: self.expected_workspace,
101                token_workspace,
102            });
103        }
104        Ok(token)
105    }
106}
107
108/// Builder for [`AccessKeyStrategy`].
109///
110/// Created via [`AccessKeyStrategy::builder`].
111pub struct AccessKeyStrategyBuilder<S = NoStore> {
112    workspace_crn: Crn,
113    access_key: SecretToken,
114    audience: Option<String>,
115    base_url_override: Option<url::Url>,
116    token_store: S,
117}
118
119impl<S> AccessKeyStrategyBuilder<S> {
120    /// Set the audience for token requests.
121    pub fn audience(mut self, audience: impl Into<String>) -> Self {
122        self.audience = Some(audience.into());
123        self
124    }
125
126    /// Override the base URL resolved by service discovery.
127    ///
128    /// Useful for pointing at a local or mock auth server during testing.
129    #[cfg(any(test, feature = "test-utils"))]
130    pub fn base_url(mut self, url: url::Url) -> Self {
131        self.base_url_override = Some(url);
132        self
133    }
134
135    /// Wire an external [`TokenStore`] into the strategy.
136    ///
137    /// On every call to [`get_token`](AuthStrategy::get_token), if no token is
138    /// cached in memory, the store is consulted before falling back to
139    /// re-authenticating with the access key. After every successful refresh
140    /// or initial auth, the new token is written back to the store. Use this
141    /// from short-lived strategy instances (Edge Functions, Workers, proxy
142    /// worker pools) to share a service-token cache across processes.
143    ///
144    /// Returns a new builder with the store type erased into the chain — see
145    /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and
146    /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made
147    /// implementations.
148    pub fn with_token_store<T: TokenStore>(self, store: T) -> AccessKeyStrategyBuilder<T> {
149        AccessKeyStrategyBuilder {
150            workspace_crn: self.workspace_crn,
151            access_key: self.access_key,
152            audience: self.audience,
153            base_url_override: self.base_url_override,
154            token_store: store,
155        }
156    }
157}
158
159impl<S: TokenStore> AccessKeyStrategyBuilder<S> {
160    /// Build the [`AccessKeyStrategy`].
161    ///
162    /// Resolves the base URL via service discovery using the CRN's region,
163    /// unless overridden with `base_url` (available when the `test-utils`
164    /// feature is enabled).
165    pub fn build(self) -> Result<AccessKeyStrategy<S>, AuthError> {
166        let expected_workspace = self.workspace_crn.workspace_id;
167        let region = self.workspace_crn.region;
168        let base_url = match self.base_url_override {
169            Some(url) => url,
170            None => {
171                crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?)
172            }
173        };
174        let refresher = AccessKeyRefresher::new(
175            self.access_key,
176            ensure_trailing_slash(base_url),
177            self.audience,
178        );
179        Ok(AccessKeyStrategy {
180            inner: AutoRefresh::with_store(refresher, self.token_store),
181            expected_workspace,
182        })
183    }
184}
185
186#[cfg(test)]
187mod workspace_verification_tests {
188    use super::*;
189    use mocktail::prelude::*;
190    use std::time::{SystemTime, UNIX_EPOCH};
191
192    /// Build a JWT carrying the given `workspace` claim. Mirrors the
193    /// helper in `node/src/mock_auth_server.rs`.
194    fn jwt_with_workspace(workspace: &str) -> String {
195        use jsonwebtoken::{encode, EncodingKey, Header};
196        #[allow(clippy::expect_used)]
197        let now = SystemTime::now()
198            .duration_since(UNIX_EPOCH)
199            .expect("system clock")
200            .as_secs();
201        let claims = serde_json::json!({
202            "iss": "https://cts.example.com/",
203            "sub": "CS|test-access-key",
204            "aud": "test-audience",
205            "iat": now,
206            "exp": now + 3600,
207            "workspace": workspace,
208            "scope": "",
209        });
210        #[allow(clippy::expect_used)]
211        encode(
212            &Header::default(),
213            &claims,
214            &EncodingKey::from_secret(b"test-secret"),
215        )
216        .expect("JWT encode")
217    }
218
219    async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer {
220        let mut mocks = MockSet::new();
221        let jwt = jwt_with_workspace(workspace);
222        mocks.mock(move |when, then| {
223            when.post().path("/api/authorise");
224            then.json(serde_json::json!({
225                "accessToken": jwt,
226                "expiry": 3600,
227            }));
228        });
229        let server = MockServer::new_http("access-key-strategy-workspace-test").with_mocks(mocks);
230        #[allow(clippy::expect_used)]
231        server.start().await.expect("mock server start");
232        server
233    }
234
235    fn crn_with_workspace(workspace: &str) -> Crn {
236        let s = format!("crn:ap-southeast-2.aws:{workspace}");
237        s.parse().expect("test CRN parses")
238    }
239
240    fn test_access_key() -> AccessKey {
241        "CSAKtestKeyId.testKeySecret"
242            .parse()
243            .expect("test access key parses")
244    }
245
246    /// Happy path — JWT workspace matches the CRN: `get_token()` returns
247    /// the token cleanly.
248    #[tokio::test]
249    async fn returns_token_when_workspace_matches() {
250        const WS: &str = "ZVATKW3VHMFG27DY";
251        let server = start_mock_server_returning_jwt(WS).await;
252        let crn = crn_with_workspace(WS);
253
254        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
255            .base_url(server.url(""))
256            .build()
257            .expect("builder");
258
259        let token = (&strategy).get_token().await.expect("get_token");
260        assert_eq!(
261            token.workspace_id().expect("workspace_id").as_str(),
262            WS,
263            "happy-path token should carry the expected workspace",
264        );
265    }
266
267    /// Mismatch — JWT workspace differs from the CRN's: `get_token()`
268    /// returns `AuthError::WorkspaceMismatch` rather than the token.
269    #[tokio::test]
270    async fn errors_when_token_workspace_differs_from_crn() {
271        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
272        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
273        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
274        let crn = crn_with_workspace(CRN_WS);
275
276        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
277            .base_url(server.url(""))
278            .build()
279            .expect("builder");
280
281        let err = (&strategy)
282            .get_token()
283            .await
284            .expect_err("expected mismatch");
285        match err {
286            AuthError::WorkspaceMismatch {
287                expected_workspace,
288                token_workspace,
289            } => {
290                assert_eq!(expected_workspace.as_str(), CRN_WS);
291                assert_eq!(token_workspace.as_str(), TOKEN_WS);
292            }
293            other => panic!("expected WorkspaceMismatch, got {other:?}"),
294        }
295        assert_eq!(
296            AuthError::WorkspaceMismatch {
297                expected_workspace: CRN_WS.parse().unwrap(),
298                token_workspace: TOKEN_WS.parse().unwrap(),
299            }
300            .error_code(),
301            "WORKSPACE_MISMATCH",
302        );
303    }
304
305    /// A CRN carrying a `service_name` component is accepted; the
306    /// `service_name` is ignored. The strategy uses only the region (for
307    /// service discovery) and the workspace ID (for token verification).
308    /// Pinned as a test rather than left to implementation drift so that a
309    /// future contributor doesn't tighten the constructor into rejecting
310    /// these CRNs without realising the docstring already promises
311    /// acceptance.
312    #[tokio::test]
313    async fn accepts_crn_with_service_name() {
314        const WS: &str = "ZVATKW3VHMFG27DY";
315        let server = start_mock_server_returning_jwt(WS).await;
316        let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms")
317            .parse()
318            .expect("CRN with service_name parses");
319
320        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
321            .base_url(server.url(""))
322            .build()
323            .expect("CRN with service_name should construct a strategy");
324
325        let token = (&strategy).get_token().await.expect("get_token");
326        assert_eq!(
327            token.workspace_id().expect("workspace_id").as_str(),
328            WS,
329            "service_name is ignored — verification still uses the workspace ID",
330        );
331    }
332
333    /// A pre-populated [`TokenStore`] returning a token for a *different*
334    /// workspace must still be rejected by the strategy's wrapper. This
335    /// is the cross-feature interaction the CRN parity work is designed
336    /// to protect — a shared cookie / KV cache between strategies bound
337    /// to different workspaces must never let a load from the store
338    /// bypass workspace verification.
339    ///
340    /// Drives the assertion without any HTTP traffic: a 500-returning
341    /// mock fails the test loudly if the strategy ever reaches the
342    /// authorise endpoint instead of trusting the store.
343    #[tokio::test]
344    async fn rejects_stored_token_for_different_workspace() {
345        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
346        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
347
348        let mut mocks = MockSet::new();
349        mocks.mock(|when, then| {
350            when.post().path("/api/authorise");
351            then.internal_server_error()
352                .json(serde_json::json!({"error": "store must satisfy the request"}));
353        });
354        let server =
355            MockServer::new_http("access-key-strategy-store-mismatch-test").with_mocks(mocks);
356        #[allow(clippy::expect_used)]
357        server.start().await.expect("mock server start");
358
359        let now = std::time::SystemTime::now()
360            .duration_since(UNIX_EPOCH)
361            .expect("system clock")
362            .as_secs();
363        let stored = crate::Token {
364            access_token: crate::SecretToken::new(jwt_with_workspace(TOKEN_WS)),
365            token_type: "Bearer".to_string(),
366            expires_at: now + 3600,
367            refresh_token: None,
368            region: None,
369            client_id: None,
370            device_instance_id: None,
371        };
372        let store = std::sync::Arc::new(crate::InMemoryTokenStore::new());
373        store.save(&stored).await;
374
375        let strategy = AccessKeyStrategy::builder(crn_with_workspace(CRN_WS), test_access_key())
376            .base_url(server.url(""))
377            .with_token_store(std::sync::Arc::clone(&store))
378            .build()
379            .expect("builder");
380
381        let err = (&strategy)
382            .get_token()
383            .await
384            .expect_err("expected mismatch from stored token");
385        assert!(
386            matches!(err, AuthError::WorkspaceMismatch { .. }),
387            "expected WorkspaceMismatch, got {err:?}",
388        );
389    }
390
391    /// Regression guard — the workspace check runs on *every* `get_token()`
392    /// call, not only on the call that triggers initial authentication.
393    /// A future optimisation that cached the "verified" result, or that
394    /// stashed the token into a field bypassing the wrapper, would let a
395    /// mismatched token slide through on the second call. Verified by
396    /// calling `get_token()` twice against the same mock and asserting
397    /// both fail with `WorkspaceMismatch`.
398    #[tokio::test]
399    async fn errors_on_each_subsequent_get_token_call() {
400        const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA";
401        const CRN_WS: &str = "ZVATKW3VHMFG27DY";
402        let server = start_mock_server_returning_jwt(TOKEN_WS).await;
403        let crn = crn_with_workspace(CRN_WS);
404
405        let strategy = AccessKeyStrategy::builder(crn, test_access_key())
406            .base_url(server.url(""))
407            .build()
408            .expect("builder");
409
410        for call in 1..=2 {
411            let result = (&strategy).get_token().await;
412            let err = match result {
413                Ok(_) => panic!("call {call}: expected Err, got Ok"),
414                Err(e) => e,
415            };
416            assert!(
417                matches!(err, AuthError::WorkspaceMismatch { .. }),
418                "call {call}: expected WorkspaceMismatch, got {err:?}",
419            );
420        }
421    }
422}