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/// # Example
22///
23/// ```no_run
24/// use stack_auth::{AuthStrategy, AutoStrategy};
25///
26/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
27/// let strategy = AutoStrategy::new()?;
28/// let token = (&strategy).get_token().await?;
29/// println!("Authenticated! token={:?}", token);
30/// # Ok(())
31/// # }
32/// ```
33pub enum AutoStrategy {
34    /// Authenticated via a static access key.
35    AccessKey(AccessKeyStrategy),
36    /// Authenticated via OAuth tokens persisted on disk.
37    OAuth(OAuthStrategy),
38}
39
40impl AutoStrategy {
41    /// Detect available credentials and build the appropriate strategy.
42    ///
43    /// See the [type-level docs](AutoStrategy) for the detection order.
44    pub fn new() -> Result<Self, AuthError> {
45        let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok();
46        let crn = std::env::var("CS_WORKSPACE_CRN").ok();
47        let store = Some(ProfileStore::resolve(None)?);
48        Self::detect(access_key, crn, store)
49    }
50
51    /// Core detection logic, separated for testability.
52    ///
53    /// Takes pre-resolved inputs rather than reading from the environment
54    /// or filesystem directly.
55    fn detect(
56        access_key: Option<String>,
57        crn: Option<String>,
58        store: Option<ProfileStore>,
59    ) -> Result<Self, AuthError> {
60        // 1. Access key from environment
61        if let Some(access_key) = access_key {
62            let crn_str = crn.ok_or(AuthError::NotAuthenticated)?;
63            let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?;
64            let key: crate::AccessKey = access_key.parse()?;
65            let strategy = AccessKeyStrategy::new_with_crn(crn, key)?;
66            return Ok(Self::AccessKey(strategy));
67        }
68
69        // 2. OAuth token from disk
70        if let Some(store) = store {
71            if store.exists_profile::<Token>() {
72                let strategy = OAuthStrategy::with_profile(store).build()?;
73                return Ok(Self::OAuth(strategy));
74            }
75        }
76
77        // 3. No credentials found
78        Err(AuthError::NotAuthenticated)
79    }
80}
81
82impl AutoStrategy {
83    /// Return the workspace CRN from the inner strategy.
84    ///
85    /// For [`AccessKeyStrategy`], this is the CRN parsed from the `CS_WORKSPACE_CRN`
86    /// environment variable. For [`OAuthStrategy`], this is extracted from the stored
87    /// token's claims.
88    pub fn workspace_crn(&self) -> Result<Crn, AuthError> {
89        match self {
90            AutoStrategy::AccessKey(inner) => inner
91                .workspace_crn()
92                .cloned()
93                .ok_or(AuthError::NotAuthenticated),
94            AutoStrategy::OAuth(inner) => inner
95                .workspace_crn()
96                .cloned()
97                .ok_or(AuthError::NotAuthenticated),
98        }
99    }
100}
101
102impl AuthStrategy for &AutoStrategy {
103    async fn get_token(self) -> Result<ServiceToken, AuthError> {
104        match self {
105            AutoStrategy::AccessKey(inner) => inner.get_token().await,
106            AutoStrategy::OAuth(inner) => inner.get_token().await,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{SecretToken, Token};
115    use std::time::{SystemTime, UNIX_EPOCH};
116
117    const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY";
118
119    fn make_oauth_token() -> Token {
120        let now = SystemTime::now()
121            .duration_since(UNIX_EPOCH)
122            .unwrap()
123            .as_secs();
124
125        let claims = serde_json::json!({
126            "iss": "https://cts.example.com/",
127            "sub": "CS|test-user",
128            "aud": "test-audience",
129            "iat": now,
130            "exp": now + 3600,
131            "workspace": "ZVATKW3VHMFG27DY",
132            "scope": "",
133        });
134
135        let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret");
136        let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap();
137
138        Token {
139            access_token: SecretToken::new(jwt),
140            token_type: "Bearer".to_string(),
141            expires_at: now + 3600,
142            refresh_token: Some(SecretToken::new("test-refresh-token")),
143            region: Some("ap-southeast-2.aws".to_string()),
144            client_id: Some("test-client-id".to_string()),
145            device_instance_id: None,
146        }
147    }
148
149    fn write_token_store(dir: &std::path::Path) -> ProfileStore {
150        let store = ProfileStore::new(dir);
151        store.save_profile(&make_oauth_token()).unwrap();
152        store
153    }
154
155    #[test]
156    fn access_key_with_valid_crn() {
157        let result = AutoStrategy::detect(
158            Some("CSAKtestKeyId.testKeySecret".into()),
159            Some(VALID_CRN.into()),
160            None,
161        );
162
163        assert!(result.is_ok());
164        assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
165    }
166
167    #[test]
168    fn access_key_without_crn_returns_not_authenticated() {
169        let result = AutoStrategy::detect(Some("CSAKtestKeyId.testKeySecret".into()), None, None);
170
171        assert!(matches!(result, Err(AuthError::NotAuthenticated)));
172    }
173
174    #[test]
175    fn invalid_access_key_format_returns_invalid_access_key() {
176        let result =
177            AutoStrategy::detect(Some("not-a-valid-key".into()), Some(VALID_CRN.into()), None);
178
179        assert!(matches!(result, Err(AuthError::InvalidAccessKey(_))));
180    }
181
182    #[test]
183    fn access_key_with_invalid_crn_returns_invalid_crn() {
184        let result = AutoStrategy::detect(
185            Some("CSAKtestKeyId.testKeySecret".into()),
186            Some("not-a-crn".into()),
187            None,
188        );
189
190        assert!(matches!(result, Err(AuthError::InvalidCrn(_))));
191    }
192
193    #[test]
194    fn oauth_store_with_valid_token() {
195        let dir = tempfile::tempdir().unwrap();
196        let store = write_token_store(dir.path());
197
198        let result = AutoStrategy::detect(None, None, Some(store));
199
200        assert!(result.is_ok());
201        assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_)));
202    }
203
204    #[test]
205    fn oauth_store_without_token_file_returns_not_authenticated() {
206        let dir = tempfile::tempdir().unwrap();
207        let store = ProfileStore::new(dir.path());
208
209        let result = AutoStrategy::detect(None, None, Some(store));
210
211        assert!(matches!(result, Err(AuthError::NotAuthenticated)));
212    }
213
214    #[test]
215    fn no_credentials_returns_not_authenticated() {
216        let result = AutoStrategy::detect(None, None, None);
217
218        assert!(matches!(result, Err(AuthError::NotAuthenticated)));
219    }
220
221    #[test]
222    fn access_key_takes_priority_over_oauth_store() {
223        let dir = tempfile::tempdir().unwrap();
224        let store = write_token_store(dir.path());
225
226        let result = AutoStrategy::detect(
227            Some("CSAKtestKeyId.testKeySecret".into()),
228            Some(VALID_CRN.into()),
229            Some(store),
230        );
231
232        assert!(result.is_ok());
233        assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_)));
234    }
235}