Skip to main content

stack_auth/
auto_strategy.rs

1use 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
9/// An [`AuthStrategy`] that automatically detects available credentials
10/// and delegates to the appropriate inner strategy.
11///
12/// # Detection order
13///
14/// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an
15///    [`AccessKeyStrategy`] is created. The region is extracted from the
16///    `CS_WORKSPACE_CRN` environment variable.
17/// 2. If a token store file exists at the default location
18///    (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it.
19/// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned.
20///
21/// # Examples
22///
23/// ```no_run
24/// use stack_auth::{AuthStrategy, AutoStrategy};
25///
26/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
27/// // Auto-detect from env vars + profile store
28/// let strategy = AutoStrategy::detect()?;
29/// let token = (&strategy).get_token().await?;
30/// println!("Authenticated! token={:?}", token);
31/// # Ok(())
32/// # }
33/// ```
34///
35/// ```no_run
36/// use stack_auth::AutoStrategy;
37///
38/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
39/// // Provide explicit values with env/profile fallback
40/// let strategy = AutoStrategy::builder()
41///     .with_access_key("CSAK...")
42///     .detect()?;
43/// # Ok(())
44/// # }
45/// ```
46pub enum AutoStrategy {
47    /// Authenticated via a static access key.
48    AccessKey(AccessKeyStrategy),
49    /// Authenticated via OAuth tokens persisted on disk.
50    OAuth(OAuthStrategy),
51}
52
53impl AutoStrategy {
54    /// Create a builder for configuring credential resolution.
55    ///
56    /// The builder lets callers provide explicit values (access key, workspace CRN)
57    /// that take precedence over environment variables and the profile store.
58    ///
59    /// # Example
60    ///
61    /// ```no_run
62    /// use stack_auth::AutoStrategy;
63    /// use cts_common::Crn;
64    ///
65    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
66    /// let crn: Crn = "crn:ap-southeast-2.aws:workspace-id".parse()?;
67    /// let strategy = AutoStrategy::builder()
68    ///     .with_access_key("CSAKmyKeyId.myKeySecret")
69    ///     .with_workspace_crn(crn)
70    ///     .detect()?;
71    /// # Ok(())
72    /// # }
73    /// ```
74    pub fn builder() -> AutoStrategyBuilder {
75        AutoStrategyBuilder {
76            access_key: None,
77            crn: None,
78        }
79    }
80
81    /// Detect credentials from environment variables and profile store.
82    ///
83    /// Equivalent to `AutoStrategy::builder().detect()`.
84    ///
85    /// Resolution order:
86    /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`]
87    /// 2. `~/.cipherstash/auth.json` → [`OAuthStrategy`]
88    /// 3. [`AuthError::NotAuthenticated`]
89    pub fn detect() -> Result<Self, AuthError> {
90        Self::builder().detect()
91    }
92
93    /// Core detection logic, separated for testability.
94    ///
95    /// Takes pre-resolved inputs rather than reading from the environment
96    /// or filesystem directly.
97    fn detect_inner(
98        access_key: Option<String>,
99        crn: Option<Crn>,
100        store: Option<ProfileStore>,
101    ) -> Result<Self, AuthError> {
102        // 1. Access key from environment
103        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        // 2. OAuth token from disk
113        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        // 3. No credentials found
121        Err(AuthError::NotAuthenticated)
122    }
123}
124
125/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect).
126///
127/// Explicit values provided via builder methods take precedence over environment variables.
128/// Environment variables take precedence over the profile store.
129///
130/// # Example
131///
132/// ```no_run
133/// use stack_auth::AutoStrategy;
134///
135/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
136/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var
137/// let strategy = AutoStrategy::builder()
138///     .with_access_key("CSAKmyKeyId.myKeySecret")
139///     .detect()?;
140/// # Ok(())
141/// # }
142/// ```
143pub struct AutoStrategyBuilder {
144    access_key: Option<String>,
145    crn: Option<Crn>,
146}
147
148impl AutoStrategyBuilder {
149    /// Provide an explicit access key. Takes precedence over env vars.
150    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    /// Provide an explicit workspace CRN. Takes precedence over env vars.
156    pub fn with_workspace_crn(mut self, crn: Crn) -> Self {
157        self.crn = Some(crn);
158        self
159    }
160
161    /// Resolve the auth strategy.
162    ///
163    /// Resolution order:
164    /// 1. Explicit values provided via builder methods
165    /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`)
166    /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth)
167    /// 4. [`AuthError::NotAuthenticated`]
168    pub fn detect(self) -> Result<AutoStrategy, AuthError> {
169        // Merge explicit values with env vars (explicit wins)
170        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        // Resolve errors (e.g. missing profile directory) are intentionally
183        // swallowed here so that env-var-only setups don't need a profile dir.
184        // If no credentials are found at all, NotAuthenticated is returned.
185        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            // Save and clear env to ensure no fallback
340            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            // Restore env
348            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            // Restore env
365            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}