Skip to main content

stack_auth/
auto_strategy.rs

1use cts_common::Crn;
2
3use crate::access_key_strategy::AccessKeyStrategy;
4use crate::device_session_strategy::DeviceSessionStrategy;
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`), a [`DeviceSessionStrategy`] 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    DeviceSession(DeviceSessionStrategy),
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` → [`DeviceSessionStrategy`]
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                crate::error::MissingWorkspaceCrn,
114            ))?;
115            let key: crate::AccessKey = access_key.parse()?;
116            let strategy = AccessKeyStrategy::new(workspace_crn, key)?;
117            return Ok(Self::AccessKey(strategy));
118        }
119
120        // 2. OAuth token from disk (in the current workspace directory)
121        if let Some(store) = store {
122            let has_token = store
123                .current_workspace_store()
124                .map(|ws| ws.exists_profile::<Token>())
125                .unwrap_or(false);
126            if has_token {
127                let strategy = DeviceSessionStrategy::with_profile(store).build()?;
128                return Ok(Self::DeviceSession(strategy));
129            }
130        }
131
132        // 3. No credentials found
133        Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated))
134    }
135
136    #[cfg(target_arch = "wasm32")]
137    fn detect_inner(access_key: Option<String>, crn: Option<Crn>) -> Result<Self, AuthError> {
138        if let Some(access_key) = access_key {
139            let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn(
140                crate::error::MissingWorkspaceCrn,
141            ))?;
142            let key: crate::AccessKey = access_key.parse()?;
143            let strategy = AccessKeyStrategy::new(workspace_crn, key)?;
144            return Ok(Self::AccessKey(strategy));
145        }
146        Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated))
147    }
148}
149
150/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect).
151///
152/// Explicit values provided via builder methods take precedence over environment variables.
153/// Environment variables take precedence over the profile store.
154///
155/// # Example
156///
157/// ```no_run
158/// use stack_auth::AutoStrategy;
159///
160/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
161/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var
162/// let strategy = AutoStrategy::builder()
163///     .with_access_key("CSAKmyKeyId.myKeySecret")
164///     .detect()?;
165/// # Ok(())
166/// # }
167/// ```
168pub struct AutoStrategyBuilder {
169    access_key: Option<String>,
170    crn: Option<Crn>,
171}
172
173impl AutoStrategyBuilder {
174    /// Provide an explicit access key. Takes precedence over env vars.
175    pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self {
176        self.access_key = Some(access_key.into());
177        self
178    }
179
180    /// Provide an explicit workspace CRN. Takes precedence over env vars.
181    pub fn with_workspace_crn(mut self, crn: Crn) -> Self {
182        self.crn = Some(crn);
183        self
184    }
185
186    /// Resolve the auth strategy.
187    ///
188    /// Resolution order:
189    /// 1. Explicit values provided via builder methods
190    /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`)
191    /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth)
192    /// 4. [`AuthError::NotAuthenticated`]
193    pub fn detect(self) -> Result<AutoStrategy, AuthError> {
194        // Merge explicit values with env vars (explicit wins)
195        let access_key = self
196            .access_key
197            .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok());
198
199        let crn = match self.crn {
200            Some(crn) => Some(crn),
201            None => std::env::var("CS_WORKSPACE_CRN")
202                .ok()
203                .map(|s| s.parse::<Crn>().map_err(AuthError::from))
204                .transpose()?,
205        };
206
207        #[cfg(not(target_arch = "wasm32"))]
208        {
209            // Resolve errors (e.g. missing profile directory) are intentionally
210            // swallowed here so that env-var-only setups don't need a profile dir.
211            // If no credentials are found at all, NotAuthenticated is returned.
212            let store = match ProfileStore::resolve(None) {
213                Ok(s) => Some(s),
214                Err(e) => {
215                    tracing::info!(error = %e, "could not resolve profile store; continuing without it");
216                    None
217                }
218            };
219            AutoStrategy::detect_inner(access_key, crn, store)
220        }
221        #[cfg(target_arch = "wasm32")]
222        {
223            AutoStrategy::detect_inner(access_key, crn)
224        }
225    }
226}
227
228impl AuthStrategy for &AutoStrategy {
229    async fn get_token(self) -> Result<ServiceToken, AuthError> {
230        match self {
231            AutoStrategy::AccessKey(inner) => inner.get_token().await,
232            AutoStrategy::DeviceSession(inner) => inner.get_token().await,
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::{SecretToken, Token};
241    use std::time::{SystemTime, UNIX_EPOCH};
242
243    const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY";
244
245    fn valid_crn() -> Crn {
246        VALID_CRN.parse().unwrap()
247    }
248
249    fn make_oauth_token() -> Token {
250        let now = SystemTime::now()
251            .duration_since(UNIX_EPOCH)
252            .unwrap()
253            .as_secs();
254
255        let claims = serde_json::json!({
256            "iss": "https://cts.example.com/",
257            "sub": "CS|test-user",
258            "aud": "test-audience",
259            "iat": now,
260            "exp": now + 3600,
261            "workspace": "ZVATKW3VHMFG27DY",
262            "scope": "",
263        });
264
265        let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret");
266        let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap();
267
268        Token {
269            access_token: SecretToken::new(jwt),
270            token_type: "Bearer".to_string(),
271            expires_at: now + 3600,
272            refresh_token: Some(SecretToken::new("test-refresh-token")),
273            region: Some("ap-southeast-2.aws".to_string()),
274            client_id: Some("test-client-id".to_string()),
275            device_instance_id: None,
276        }
277    }
278
279    fn write_token_store(dir: &std::path::Path) -> ProfileStore {
280        let store = ProfileStore::new(dir);
281        store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
282        let ws_store = store.current_workspace_store().unwrap();
283        ws_store.save_profile(&make_oauth_token()).unwrap();
284        store
285    }
286
287    mod detect_inner {
288        use super::*;
289
290        #[test]
291        fn access_key_with_valid_crn() {
292            let result = AutoStrategy::detect_inner(
293                Some("CSAKtestKeyId.testKeySecret".into()),
294                Some(valid_crn()),
295                None,
296            );
297
298            assert!(result.is_ok());
299            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
300        }
301
302        #[test]
303        fn access_key_without_crn_returns_missing_workspace_crn() {
304            let result =
305                AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None);
306
307            assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_))));
308        }
309
310        #[test]
311        fn invalid_access_key_format_returns_invalid_access_key() {
312            let result =
313                AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None);
314
315            assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
316        }
317
318        #[test]
319        fn oauth_store_with_valid_token() {
320            let dir = tempfile::tempdir().unwrap();
321            let store = write_token_store(dir.path());
322
323            let result = AutoStrategy::detect_inner(None, None, Some(store));
324
325            assert!(result.is_ok());
326            assert!(matches!(result.unwrap(), AutoStrategy::DeviceSession(_)));
327        }
328
329        #[test]
330        fn oauth_store_without_token_file_returns_not_authenticated() {
331            let dir = tempfile::tempdir().unwrap();
332            let store = ProfileStore::new(dir.path());
333
334            let result = AutoStrategy::detect_inner(None, None, Some(store));
335
336            assert!(matches!(result, Err(AuthError::NotAuthenticated(_))));
337        }
338
339        #[test]
340        fn no_credentials_returns_not_authenticated() {
341            let result = AutoStrategy::detect_inner(None, None, None);
342
343            assert!(matches!(result, Err(AuthError::NotAuthenticated(_))));
344        }
345
346        #[test]
347        fn access_key_takes_priority_over_oauth_store() {
348            let dir = tempfile::tempdir().unwrap();
349            let store = write_token_store(dir.path());
350
351            let result = AutoStrategy::detect_inner(
352                Some("CSAKtestKeyId.testKeySecret".into()),
353                Some(valid_crn()),
354                Some(store),
355            );
356
357            assert!(result.is_ok());
358            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
359        }
360    }
361
362    mod builder {
363        use super::*;
364
365        #[test]
366        fn explicit_access_key_and_crn() {
367            let result = AutoStrategy::builder()
368                .with_access_key("CSAKtestKeyId.testKeySecret")
369                .with_workspace_crn(valid_crn())
370                .detect();
371
372            assert!(result.is_ok());
373            assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
374        }
375
376        #[test]
377        fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() {
378            // Save and clear env to ensure no fallback
379            let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
380            std::env::remove_var("CS_WORKSPACE_CRN");
381
382            let result = AutoStrategy::builder()
383                .with_access_key("CSAKtestKeyId.testKeySecret")
384                .detect();
385
386            // Restore env
387            if let Some(val) = saved_crn {
388                std::env::set_var("CS_WORKSPACE_CRN", val);
389            }
390
391            assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_))));
392        }
393
394        #[test]
395        fn invalid_crn_env_var_returns_invalid_crn() {
396            let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok();
397            std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn");
398
399            let result = AutoStrategy::builder()
400                .with_access_key("CSAKtestKeyId.testKeySecret")
401                .detect();
402
403            // Restore env
404            match saved_crn {
405                Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val),
406                None => std::env::remove_var("CS_WORKSPACE_CRN"),
407            }
408
409            assert!(matches!(result, Err(AuthError::InvalidCrn(_))));
410        }
411
412        #[test]
413        fn invalid_explicit_access_key_returns_invalid_access_key() {
414            let result = AutoStrategy::builder()
415                .with_access_key("not-a-valid-key")
416                .with_workspace_crn(valid_crn())
417                .detect();
418
419            assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
420        }
421    }
422}