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