stack_auth/
auto_strategy.rs1use cts_common::Crn;
2
3use crate::access_key_strategy::AccessKeyStrategy;
4use crate::oauth_strategy::OAuthStrategy;
5#[cfg(not(target_arch = "wasm32"))]
6use stack_profile::ProfileStore;
7
8#[cfg(not(target_arch = "wasm32"))]
9use crate::Token;
10use crate::{AuthError, AuthStrategy, ServiceToken};
11
12pub enum AutoStrategy {
50 AccessKey(AccessKeyStrategy),
52 OAuth(OAuthStrategy),
54}
55
56impl AutoStrategy {
57 pub fn builder() -> AutoStrategyBuilder {
78 AutoStrategyBuilder {
79 access_key: None,
80 crn: None,
81 }
82 }
83
84 pub fn detect() -> Result<Self, AuthError> {
93 Self::builder().detect()
94 }
95
96 #[cfg(not(target_arch = "wasm32"))]
102 fn detect_inner(
103 access_key: Option<String>,
104 crn: Option<Crn>,
105 store: Option<ProfileStore>,
106 ) -> Result<Self, AuthError> {
107 if let Some(access_key) = access_key {
109 let region = crn
110 .map(|c| c.region)
111 .ok_or(AuthError::MissingWorkspaceCrn)?;
112 let key: crate::AccessKey = access_key.parse()?;
113 let strategy = AccessKeyStrategy::new(region, key)?;
114 return Ok(Self::AccessKey(strategy));
115 }
116
117 if let Some(store) = store {
119 let has_token = store
120 .current_workspace_store()
121 .map(|ws| ws.exists_profile::<Token>())
122 .unwrap_or(false);
123 if has_token {
124 let strategy = OAuthStrategy::with_profile(store).build()?;
125 return Ok(Self::OAuth(strategy));
126 }
127 }
128
129 Err(AuthError::NotAuthenticated)
131 }
132
133 #[cfg(target_arch = "wasm32")]
134 fn detect_inner(access_key: Option<String>, crn: Option<Crn>) -> Result<Self, AuthError> {
135 if let Some(access_key) = access_key {
136 let region = crn
137 .map(|c| c.region)
138 .ok_or(AuthError::MissingWorkspaceCrn)?;
139 let key: crate::AccessKey = access_key.parse()?;
140 let strategy = AccessKeyStrategy::new(region, key)?;
141 return Ok(Self::AccessKey(strategy));
142 }
143 Err(AuthError::NotAuthenticated)
144 }
145}
146
147pub struct AutoStrategyBuilder {
166 access_key: Option<String>,
167 crn: Option<Crn>,
168}
169
170impl AutoStrategyBuilder {
171 pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self {
173 self.access_key = Some(access_key.into());
174 self
175 }
176
177 pub fn with_workspace_crn(mut self, crn: Crn) -> Self {
179 self.crn = Some(crn);
180 self
181 }
182
183 pub fn detect(self) -> Result<AutoStrategy, AuthError> {
191 let access_key = self
193 .access_key
194 .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok());
195
196 let crn = match self.crn {
197 Some(crn) => Some(crn),
198 None => std::env::var("CS_WORKSPACE_CRN")
199 .ok()
200 .map(|s| s.parse::<Crn>().map_err(AuthError::InvalidCrn))
201 .transpose()?,
202 };
203
204 #[cfg(not(target_arch = "wasm32"))]
205 {
206 let store = match ProfileStore::resolve(None) {
210 Ok(s) => Some(s),
211 Err(e) => {
212 tracing::info!(error = %e, "could not resolve profile store; continuing without it");
213 None
214 }
215 };
216 AutoStrategy::detect_inner(access_key, crn, store)
217 }
218 #[cfg(target_arch = "wasm32")]
219 {
220 AutoStrategy::detect_inner(access_key, crn)
221 }
222 }
223}
224
225impl AuthStrategy for &AutoStrategy {
226 async fn get_token(self) -> Result<ServiceToken, AuthError> {
227 match self {
228 AutoStrategy::AccessKey(inner) => inner.get_token().await,
229 AutoStrategy::OAuth(inner) => inner.get_token().await,
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::{SecretToken, Token};
238 use std::time::{SystemTime, UNIX_EPOCH};
239
240 const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY";
241
242 fn valid_crn() -> Crn {
243 VALID_CRN.parse().unwrap()
244 }
245
246 fn make_oauth_token() -> Token {
247 let now = SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .unwrap()
250 .as_secs();
251
252 let claims = serde_json::json!({
253 "iss": "https://cts.example.com/",
254 "sub": "CS|test-user",
255 "aud": "test-audience",
256 "iat": now,
257 "exp": now + 3600,
258 "workspace": "ZVATKW3VHMFG27DY",
259 "scope": "",
260 });
261
262 let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret");
263 let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap();
264
265 Token {
266 access_token: SecretToken::new(jwt),
267 token_type: "Bearer".to_string(),
268 expires_at: now + 3600,
269 refresh_token: Some(SecretToken::new("test-refresh-token")),
270 region: Some("ap-southeast-2.aws".to_string()),
271 client_id: Some("test-client-id".to_string()),
272 device_instance_id: None,
273 }
274 }
275
276 fn write_token_store(dir: &std::path::Path) -> ProfileStore {
277 let store = ProfileStore::new(dir);
278 store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
279 let ws_store = store.current_workspace_store().unwrap();
280 ws_store.save_profile(&make_oauth_token()).unwrap();
281 store
282 }
283
284 mod detect_inner {
285 use super::*;
286
287 #[test]
288 fn access_key_with_valid_crn() {
289 let result = AutoStrategy::detect_inner(
290 Some("CSAKtestKeyId.testKeySecret".into()),
291 Some(valid_crn()),
292 None,
293 );
294
295 assert!(result.is_ok());
296 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
297 }
298
299 #[test]
300 fn access_key_without_crn_returns_missing_workspace_crn() {
301 let result =
302 AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None);
303
304 assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
305 }
306
307 #[test]
308 fn invalid_access_key_format_returns_invalid_access_key() {
309 let result =
310 AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None);
311
312 assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
313 }
314
315 #[test]
316 fn oauth_store_with_valid_token() {
317 let dir = tempfile::tempdir().unwrap();
318 let store = write_token_store(dir.path());
319
320 let result = AutoStrategy::detect_inner(None, None, Some(store));
321
322 assert!(result.is_ok());
323 assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_)));
324 }
325
326 #[test]
327 fn oauth_store_without_token_file_returns_not_authenticated() {
328 let dir = tempfile::tempdir().unwrap();
329 let store = ProfileStore::new(dir.path());
330
331 let result = AutoStrategy::detect_inner(None, None, Some(store));
332
333 assert!(matches!(result, Err(AuthError::NotAuthenticated)));
334 }
335
336 #[test]
337 fn no_credentials_returns_not_authenticated() {
338 let result = AutoStrategy::detect_inner(None, None, None);
339
340 assert!(matches!(result, Err(AuthError::NotAuthenticated)));
341 }
342
343 #[test]
344 fn access_key_takes_priority_over_oauth_store() {
345 let dir = tempfile::tempdir().unwrap();
346 let store = write_token_store(dir.path());
347
348 let result = AutoStrategy::detect_inner(
349 Some("CSAKtestKeyId.testKeySecret".into()),
350 Some(valid_crn()),
351 Some(store),
352 );
353
354 assert!(result.is_ok());
355 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
356 }
357 }
358
359 mod builder {
360 use super::*;
361
362 #[test]
363 fn explicit_access_key_and_crn() {
364 let result = AutoStrategy::builder()
365 .with_access_key("CSAKtestKeyId.testKeySecret")
366 .with_workspace_crn(valid_crn())
367 .detect();
368
369 assert!(result.is_ok());
370 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
371 }
372
373 #[test]
374 fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() {
375 let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
377 std::env::remove_var("CS_WORKSPACE_CRN");
378
379 let result = AutoStrategy::builder()
380 .with_access_key("CSAKtestKeyId.testKeySecret")
381 .detect();
382
383 if let Some(val) = saved_crn {
385 std::env::set_var("CS_WORKSPACE_CRN", val);
386 }
387
388 assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
389 }
390
391 #[test]
392 fn invalid_crn_env_var_returns_invalid_crn() {
393 let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
394 std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn");
395
396 let result = AutoStrategy::builder()
397 .with_access_key("CSAKtestKeyId.testKeySecret")
398 .detect();
399
400 match saved_crn {
402 Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val),
403 None => std::env::remove_var("CS_WORKSPACE_CRN"),
404 }
405
406 assert!(matches!(result, Err(AuthError::InvalidCrn(_))));
407 }
408
409 #[test]
410 fn invalid_explicit_access_key_returns_invalid_access_key() {
411 let result = AutoStrategy::builder()
412 .with_access_key("not-a-valid-key")
413 .with_workspace_crn(valid_crn())
414 .detect();
415
416 assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
417 }
418 }
419}