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