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
9pub struct AccessKeyStrategy<S = NoStore> {
47 inner: AutoRefresh<AccessKeyRefresher, S>,
48 expected_workspace: WorkspaceId,
49}
50
51impl AccessKeyStrategy {
52 pub fn new(workspace_crn: Crn, access_key: AccessKey) -> Result<Self, AuthError> {
65 Self::builder(workspace_crn, access_key).build()
66 }
67
68 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
108pub 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 pub fn audience(mut self, audience: impl Into<String>) -> Self {
122 self.audience = Some(audience.into());
123 self
124 }
125
126 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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}