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