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 workspace CRN is parsed from
19///    `CS_WORKSPACE_CRN` (or the explicit
20///    [`with_workspace_crn`](AutoStrategyBuilder::with_workspace_crn) value);
21///    its region drives service discovery and its workspace ID is used
22///    to verify every issued token.
23/// 2. If a token store file exists at the default location
24///    (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it.
25/// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned.
26///
27/// # Examples
28///
29/// ```no_run
30/// use stack_auth::{AuthStrategy, AutoStrategy};
31///
32/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
33/// // Auto-detect from env vars + profile store
34/// let strategy = AutoStrategy::detect()?;
35/// let token = (&strategy).get_token().await?;
36/// println!("Authenticated! token={:?}", token);
37/// # Ok(())
38/// # }
39/// ```
40///
41/// ```no_run
42/// use stack_auth::AutoStrategy;
43///
44/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
45/// // Provide explicit values with env/profile fallback
46/// let strategy = AutoStrategy::builder()
47///     .with_access_key("CSAK...")
48///     .detect()?;
49/// # Ok(())
50/// # }
51/// ```
52pub enum AutoStrategy {
53    /// Authenticated via a static access key.
54    AccessKey(AccessKeyStrategy),
55    /// Authenticated via OAuth tokens persisted on disk.
56    OAuth(OAuthStrategy),
57}
58
59impl AutoStrategy {
60    /// Create a builder for configuring credential resolution.
61    ///
62    /// The builder lets callers provide explicit values (access key, workspace CRN)
63    /// that take precedence over environment variables and the profile store.
64    ///
65    /// # Example
66    ///
67    /// ```no_run
68    /// use stack_auth::AutoStrategy;
69    /// use cts_common::Crn;
70    ///
71    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
72    /// let crn: Crn = "crn:ap-southeast-2.aws:workspace-id".parse()?;
73    /// let strategy = AutoStrategy::builder()
74    ///     .with_access_key("CSAKmyKeyId.myKeySecret")
75    ///     .with_workspace_crn(crn)
76    ///     .detect()?;
77    /// # Ok(())
78    /// # }
79    /// ```
80    pub fn builder() -> AutoStrategyBuilder {
81        AutoStrategyBuilder {
82            access_key: None,
83            crn: None,
84        }
85    }
86
87    /// Detect credentials from environment variables and profile store.
88    ///
89    /// Equivalent to `AutoStrategy::builder().detect()`.
90    ///
91    /// Resolution order:
92    /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`]
93    /// 2. `~/.cipherstash/auth.json` → [`OAuthStrategy`]
94    /// 3. [`AuthError::NotAuthenticated`]
95    pub fn detect() -> Result<Self, AuthError> {
96        Self::builder().detect()
97    }
98
99    /// Core detection logic, separated for testability.
100    ///
101    /// Takes pre-resolved inputs rather than reading from the environment
102    /// or filesystem directly. On wasm32 the profile-store fallback is
103    /// unreachable (no filesystem) — callers must supply an access key.
104    #[cfg(not(target_arch = "wasm32"))]
105    fn detect_inner(
106        access_key: Option<String>,
107        crn: Option<Crn>,
108        store: Option<ProfileStore>,
109    ) -> Result<Self, AuthError> {
110        // 1. Access key from environment
111        if let Some(access_key) = access_key {
112            let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?;
113            let key: crate::AccessKey = access_key.parse()?;
114            let strategy = AccessKeyStrategy::new(workspace_crn, key)?;
115            return Ok(Self::AccessKey(strategy));
116        }
117
118        // 2. OAuth token from disk (in the current workspace directory)
119        if let Some(store) = store {
120            let has_token = store
121                .current_workspace_store()
122                .map(|ws| ws.exists_profile::<Token>())
123                .unwrap_or(false);
124            if has_token {
125                let strategy = OAuthStrategy::with_profile(store).build()?;
126                return Ok(Self::OAuth(strategy));
127            }
128        }
129
130        // 3. No credentials found
131        Err(AuthError::NotAuthenticated)
132    }
133
134    #[cfg(target_arch = "wasm32")]
135    fn detect_inner(access_key: Option<String>, crn: Option<Crn>) -> Result<Self, AuthError> {
136        if let Some(access_key) = access_key {
137            let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?;
138            let key: crate::AccessKey = access_key.parse()?;
139            let strategy = AccessKeyStrategy::new(workspace_crn, key)?;
140            return Ok(Self::AccessKey(strategy));
141        }
142        Err(AuthError::NotAuthenticated)
143    }
144}
145
146/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect).
147///
148/// Explicit values provided via builder methods take precedence over environment variables.
149/// Environment variables take precedence over the profile store.
150///
151/// # Example
152///
153/// ```no_run
154/// use stack_auth::AutoStrategy;
155///
156/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
157/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var
158/// let strategy = AutoStrategy::builder()
159///     .with_access_key("CSAKmyKeyId.myKeySecret")
160///     .detect()?;
161/// # Ok(())
162/// # }
163/// ```
164pub struct AutoStrategyBuilder {
165    access_key: Option<String>,
166    crn: Option<Crn>,
167}
168
169impl AutoStrategyBuilder {
170    /// Provide an explicit access key. Takes precedence over env vars.
171    pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self {
172        self.access_key = Some(access_key.into());
173        self
174    }
175
176    /// Provide an explicit workspace CRN. Takes precedence over env vars.
177    pub fn with_workspace_crn(mut self, crn: Crn) -> Self {
178        self.crn = Some(crn);
179        self
180    }
181
182    /// Resolve the auth strategy.
183    ///
184    /// Resolution order:
185    /// 1. Explicit values provided via builder methods
186    /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`)
187    /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth)
188    /// 4. [`AuthError::NotAuthenticated`]
189    pub fn detect(self) -> Result<AutoStrategy, AuthError> {
190        // Merge explicit values with env vars (explicit wins)
191        let access_key = self
192            .access_key
193            .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok());
194
195        let crn = match self.crn {
196            Some(crn) => Some(crn),
197            None => std::env::var("CS_WORKSPACE_CRN")
198                .ok()
199                .map(|s| s.parse::<Crn>().map_err(AuthError::InvalidCrn))
200                .transpose()?,
201        };
202
203        #[cfg(not(target_arch = "wasm32"))]
204        {
205            // Resolve errors (e.g. missing profile directory) are intentionally
206            // swallowed here so that env-var-only setups don't need a profile dir.
207            // If no credentials are found at all, NotAuthenticated is returned.
208            let store = match ProfileStore::resolve(None) {
209                Ok(s) => Some(s),
210                Err(e) => {
211                    tracing::info!(error = %e, "could not resolve profile store; continuing without it");
212                    None
213                }
214            };
215            AutoStrategy::detect_inner(access_key, crn, store)
216        }
217        #[cfg(target_arch = "wasm32")]
218        {
219            AutoStrategy::detect_inner(access_key, crn)
220        }
221    }
222}
223
224impl AuthStrategy for &AutoStrategy {
225    async fn get_token(self) -> Result<ServiceToken, AuthError> {
226        match self {
227            AutoStrategy::AccessKey(inner) => inner.get_token().await,
228            AutoStrategy::OAuth(inner) => inner.get_token().await,
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::{SecretToken, Token};
237    use std::time::{SystemTime, UNIX_EPOCH};
238
239    const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY";
240
241    fn valid_crn() -> Crn {
242        VALID_CRN.parse().unwrap()
243    }
244
245    fn make_oauth_token() -> Token {
246        let now = SystemTime::now()
247            .duration_since(UNIX_EPOCH)
248            .unwrap()
249            .as_secs();
250
251        let claims = serde_json::json!({
252            "iss": "https://cts.example.com/",
253            "sub": "CS|test-user",
254            "aud": "test-audience",
255            "iat": now,
256            "exp": now + 3600,
257            "workspace": "ZVATKW3VHMFG27DY",
258            "scope": "",
259        });
260
261        let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret");
262        let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap();
263
264        Token {
265            access_token: SecretToken::new(jwt),
266            token_type: "Bearer".to_string(),
267            expires_at: now + 3600,
268            refresh_token: Some(SecretToken::new("test-refresh-token")),
269            region: Some("ap-southeast-2.aws".to_string()),
270            client_id: Some("test-client-id".to_string()),
271            device_instance_id: None,
272        }
273    }
274
275    fn write_token_store(dir: &std::path::Path) -> ProfileStore {
276        let store = ProfileStore::new(dir);
277        store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
278        let ws_store = store.current_workspace_store().unwrap();
279        ws_store.save_profile(&make_oauth_token()).unwrap();
280        store
281    }
282
283    mod detect_inner {
284        use super::*;
285
286        #[test]
287        fn access_key_with_valid_crn() {
288            let result = AutoStrategy::detect_inner(
289                Some("CSAKtestKeyId.testKeySecret".into()),
290                Some(valid_crn()),
291                None,
292            );
293
294            assert!(result.is_ok());
295            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
296        }
297
298        #[test]
299        fn access_key_without_crn_returns_missing_workspace_crn() {
300            let result =
301                AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None);
302
303            assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
304        }
305
306        #[test]
307        fn invalid_access_key_format_returns_invalid_access_key() {
308            let result =
309                AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None);
310
311            assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
312        }
313
314        #[test]
315        fn oauth_store_with_valid_token() {
316            let dir = tempfile::tempdir().unwrap();
317            let store = write_token_store(dir.path());
318
319            let result = AutoStrategy::detect_inner(None, None, Some(store));
320
321            assert!(result.is_ok());
322            assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_)));
323        }
324
325        #[test]
326        fn oauth_store_without_token_file_returns_not_authenticated() {
327            let dir = tempfile::tempdir().unwrap();
328            let store = ProfileStore::new(dir.path());
329
330            let result = AutoStrategy::detect_inner(None, None, Some(store));
331
332            assert!(matches!(result, Err(AuthError::NotAuthenticated)));
333        }
334
335        #[test]
336        fn no_credentials_returns_not_authenticated() {
337            let result = AutoStrategy::detect_inner(None, None, None);
338
339            assert!(matches!(result, Err(AuthError::NotAuthenticated)));
340        }
341
342        #[test]
343        fn access_key_takes_priority_over_oauth_store() {
344            let dir = tempfile::tempdir().unwrap();
345            let store = write_token_store(dir.path());
346
347            let result = AutoStrategy::detect_inner(
348                Some("CSAKtestKeyId.testKeySecret".into()),
349                Some(valid_crn()),
350                Some(store),
351            );
352
353            assert!(result.is_ok());
354            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
355        }
356    }
357
358    mod builder {
359        use super::*;
360
361        #[test]
362        fn explicit_access_key_and_crn() {
363            let result = AutoStrategy::builder()
364                .with_access_key("CSAKtestKeyId.testKeySecret")
365                .with_workspace_crn(valid_crn())
366                .detect();
367
368            assert!(result.is_ok());
369            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
370        }
371
372        #[test]
373        fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() {
374            // Save and clear env to ensure no fallback
375            let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
376            std::env::remove_var("CS_WORKSPACE_CRN");
377
378            let result = AutoStrategy::builder()
379                .with_access_key("CSAKtestKeyId.testKeySecret")
380                .detect();
381
382            // Restore env
383            if let Some(val) = saved_crn {
384                std::env::set_var("CS_WORKSPACE_CRN", val);
385            }
386
387            assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn)));
388        }
389
390        #[test]
391        fn invalid_crn_env_var_returns_invalid_crn() {
392            let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
393            std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn");
394
395            let result = AutoStrategy::builder()
396                .with_access_key("CSAKtestKeyId.testKeySecret")
397                .detect();
398
399            // Restore env
400            match saved_crn {
401                Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val),
402                None => std::env::remove_var("CS_WORKSPACE_CRN"),
403            }
404
405            assert!(matches!(result, Err(AuthError::InvalidCrn(_))));
406        }
407
408        #[test]
409        fn invalid_explicit_access_key_returns_invalid_access_key() {
410            let result = AutoStrategy::builder()
411                .with_access_key("not-a-valid-key")
412                .with_workspace_crn(valid_crn())
413                .detect();
414
415            assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
416        }
417    }
418}