stack_auth/
auto_strategy.rs1use cts_common::Crn;
2
3use crate::access_key_strategy::AccessKeyStrategy;
4use crate::oauth_strategy::OAuthStrategy;
5use stack_profile::ProfileStore;
6
7use crate::{AuthError, AuthStrategy, ServiceToken, Token};
8
9pub enum AutoStrategy {
47 AccessKey(AccessKeyStrategy),
49 OAuth(OAuthStrategy),
51}
52
53impl AutoStrategy {
54 pub fn builder() -> AutoStrategyBuilder {
75 AutoStrategyBuilder {
76 access_key: None,
77 crn: None,
78 }
79 }
80
81 pub fn detect() -> Result<Self, AuthError> {
90 Self::builder().detect()
91 }
92
93 fn detect_inner(
98 access_key: Option<String>,
99 crn: Option<Crn>,
100 store: Option<ProfileStore>,
101 ) -> Result<Self, AuthError> {
102 if let Some(access_key) = access_key {
104 let region = crn
105 .map(|c| c.region)
106 .ok_or(AuthError::MissingWorkspaceCrn)?;
107 let key: crate::AccessKey = access_key.parse()?;
108 let strategy = AccessKeyStrategy::new(region, key)?;
109 return Ok(Self::AccessKey(strategy));
110 }
111
112 if let Some(store) = store {
114 let has_token = store
115 .current_workspace_store()
116 .map(|ws| ws.exists_profile::<Token>())
117 .unwrap_or(false);
118 if has_token {
119 let strategy = OAuthStrategy::with_profile(store).build()?;
120 return Ok(Self::OAuth(strategy));
121 }
122 }
123
124 Err(AuthError::NotAuthenticated)
126 }
127}
128
129pub struct AutoStrategyBuilder {
148 access_key: Option<String>,
149 crn: Option<Crn>,
150}
151
152impl AutoStrategyBuilder {
153 pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self {
155 self.access_key = Some(access_key.into());
156 self
157 }
158
159 pub fn with_workspace_crn(mut self, crn: Crn) -> Self {
161 self.crn = Some(crn);
162 self
163 }
164
165 pub fn detect(self) -> Result<AutoStrategy, AuthError> {
173 let access_key = self
175 .access_key
176 .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok());
177
178 let crn = match self.crn {
179 Some(crn) => Some(crn),
180 None => std::env::var("CS_WORKSPACE_CRN")
181 .ok()
182 .map(|s| s.parse::<Crn>().map_err(AuthError::InvalidCrn))
183 .transpose()?,
184 };
185
186 let store = ProfileStore::resolve(None).ok();
190
191 AutoStrategy::detect_inner(access_key, crn, store)
192 }
193}
194
195impl AuthStrategy for &AutoStrategy {
196 async fn get_token(self) -> Result<ServiceToken, AuthError> {
197 match self {
198 AutoStrategy::AccessKey(inner) => inner.get_token().await,
199 AutoStrategy::OAuth(inner) => inner.get_token().await,
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::{SecretToken, Token};
208 use std::time::{SystemTime, UNIX_EPOCH};
209
210 const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY";
211
212 fn valid_crn() -> Crn {
213 VALID_CRN.parse().unwrap()
214 }
215
216 fn make_oauth_token() -> Token {
217 let now = SystemTime::now()
218 .duration_since(UNIX_EPOCH)
219 .unwrap()
220 .as_secs();
221
222 let claims = serde_json::json!({
223 "iss": "https://cts.example.com/",
224 "sub": "CS|test-user",
225 "aud": "test-audience",
226 "iat": now,
227 "exp": now + 3600,
228 "workspace": "ZVATKW3VHMFG27DY",
229 "scope": "",
230 });
231
232 let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret");
233 let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap();
234
235 Token {
236 access_token: SecretToken::new(jwt),
237 token_type: "Bearer".to_string(),
238 expires_at: now + 3600,
239 refresh_token: Some(SecretToken::new("test-refresh-token")),
240 region: Some("ap-southeast-2.aws".to_string()),
241 client_id: Some("test-client-id".to_string()),
242 device_instance_id: None,
243 }
244 }
245
246 fn write_token_store(dir: &std::path::Path) -> ProfileStore {
247 let store = ProfileStore::new(dir);
248 store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
249 let ws_store = store.current_workspace_store().unwrap();
250 ws_store.save_profile(&make_oauth_token()).unwrap();
251 store
252 }
253
254 mod detect_inner {
255 use super::*;
256
257 #[test]
258 fn access_key_with_valid_crn() {
259 let result = AutoStrategy::detect_inner(
260 Some("CSAKtestKeyId.testKeySecret".into()),
261 Some(valid_crn()),
262 None,
263 );
264
265 assert!(result.is_ok());
266 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
267 }
268
269 #[test]
270 fn access_key_without_crn_returns_missing_workspace_crn() {
271 let result =
272 AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None);
273
274 assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
275 }
276
277 #[test]
278 fn invalid_access_key_format_returns_invalid_access_key() {
279 let result =
280 AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None);
281
282 assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
283 }
284
285 #[test]
286 fn oauth_store_with_valid_token() {
287 let dir = tempfile::tempdir().unwrap();
288 let store = write_token_store(dir.path());
289
290 let result = AutoStrategy::detect_inner(None, None, Some(store));
291
292 assert!(result.is_ok());
293 assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_)));
294 }
295
296 #[test]
297 fn oauth_store_without_token_file_returns_not_authenticated() {
298 let dir = tempfile::tempdir().unwrap();
299 let store = ProfileStore::new(dir.path());
300
301 let result = AutoStrategy::detect_inner(None, None, Some(store));
302
303 assert!(matches!(result, Err(AuthError::NotAuthenticated)));
304 }
305
306 #[test]
307 fn no_credentials_returns_not_authenticated() {
308 let result = AutoStrategy::detect_inner(None, None, None);
309
310 assert!(matches!(result, Err(AuthError::NotAuthenticated)));
311 }
312
313 #[test]
314 fn access_key_takes_priority_over_oauth_store() {
315 let dir = tempfile::tempdir().unwrap();
316 let store = write_token_store(dir.path());
317
318 let result = AutoStrategy::detect_inner(
319 Some("CSAKtestKeyId.testKeySecret".into()),
320 Some(valid_crn()),
321 Some(store),
322 );
323
324 assert!(result.is_ok());
325 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
326 }
327 }
328
329 mod builder {
330 use super::*;
331
332 #[test]
333 fn explicit_access_key_and_crn() {
334 let result = AutoStrategy::builder()
335 .with_access_key("CSAKtestKeyId.testKeySecret")
336 .with_workspace_crn(valid_crn())
337 .detect();
338
339 assert!(result.is_ok());
340 assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
341 }
342
343 #[test]
344 fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() {
345 let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
347 std::env::remove_var("CS_WORKSPACE_CRN");
348
349 let result = AutoStrategy::builder()
350 .with_access_key("CSAKtestKeyId.testKeySecret")
351 .detect();
352
353 if let Some(val) = saved_crn {
355 std::env::set_var("CS_WORKSPACE_CRN", val);
356 }
357
358 assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
359 }
360
361 #[test]
362 fn invalid_crn_env_var_returns_invalid_crn() {
363 let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
364 std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn");
365
366 let result = AutoStrategy::builder()
367 .with_access_key("CSAKtestKeyId.testKeySecret")
368 .detect();
369
370 match saved_crn {
372 Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val),
373 None => std::env::remove_var("CS_WORKSPACE_CRN"),
374 }
375
376 assert!(matches!(result, Err(AuthError::InvalidCrn(_))));
377 }
378
379 #[test]
380 fn invalid_explicit_access_key_returns_invalid_access_key() {
381 let result = AutoStrategy::builder()
382 .with_access_key("not-a-valid-key")
383 .with_workspace_crn(valid_crn())
384 .detect();
385
386 assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
387 }
388 }
389}