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