Skip to main content

stack_auth/
device_session_strategy.rs

1use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery};
2use tracing::warn;
3
4#[cfg(not(target_arch = "wasm32"))]
5use stack_profile::ProfileStore;
6
7use crate::auto_refresh::AutoRefresh;
8use crate::device_session_refresher::DeviceSessionRefresher;
9use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken, Token};
10
11/// An [`AuthStrategy`] that renews a CTS session minted by an interactive
12/// OAuth login (the device-code flow), using its OAuth refresh token.
13///
14/// This *renews* an existing CTS session — it cannot federate a raw
15/// third-party JWT. For that, see [`OidcFederationStrategy`](crate::OidcFederationStrategy).
16///
17/// # Construction
18///
19/// Use [`DeviceSessionStrategy::with_token`] with a token obtained from a device code flow
20/// (or any other OAuth flow) for in-memory caching only. Use
21/// [`DeviceSessionStrategy::with_profile`] to load a token from disk and persist
22/// refreshed tokens back to the store.
23///
24/// # Example
25///
26/// ```no_run
27/// use stack_auth::{DeviceSessionStrategy, Token};
28/// use cts_common::Region;
29///
30/// # fn run(token: Token) -> Result<(), Box<dyn std::error::Error>> {
31/// let region = Region::aws("ap-southeast-2")?;
32/// let strategy = DeviceSessionStrategy::with_token(region, "my-client-id", token).build()?;
33/// # Ok(())
34/// # }
35/// ```
36pub struct DeviceSessionStrategy {
37    crn: Option<Crn>,
38    inner: AutoRefresh<DeviceSessionRefresher>,
39}
40
41impl DeviceSessionStrategy {
42    /// Return a builder for configuring a `DeviceSessionStrategy` from a token.
43    ///
44    /// The token's `region` and `client_id` fields are set before caching.
45    /// No token store is used — tokens are not persisted to disk.
46    pub fn with_token(
47        region: Region,
48        client_id: impl Into<String>,
49        token: Token,
50    ) -> DeviceSessionStrategyBuilder {
51        DeviceSessionStrategyBuilder {
52            source: OAuthTokenSource::Token {
53                region,
54                client_id: client_id.into(),
55                token,
56            },
57            base_url_override: None,
58        }
59    }
60
61    /// Return a builder for configuring a `DeviceSessionStrategy` from a profile store.
62    ///
63    /// The token is loaded from the store when [`DeviceSessionStrategyBuilder::build`] is called.
64    /// The builder allows further configuration (e.g. overriding the base URL) before building.
65    ///
66    /// The token must have `region` and `client_id` set (as saved by
67    /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior
68    /// `DeviceSessionStrategy`). The store is used for persisting refreshed tokens.
69    #[cfg(not(target_arch = "wasm32"))]
70    pub fn with_profile(store: ProfileStore) -> DeviceSessionStrategyBuilder {
71        DeviceSessionStrategyBuilder {
72            source: OAuthTokenSource::Store(store),
73            base_url_override: None,
74        }
75    }
76
77    /// Return the workspace CRN, if one was extracted from the token at build time.
78    pub fn workspace_crn(&self) -> Option<&Crn> {
79        self.crn.as_ref()
80    }
81}
82
83impl AuthStrategy for &DeviceSessionStrategy {
84    async fn get_token(self) -> Result<ServiceToken, AuthError> {
85        Ok(self.inner.get_token().await?)
86    }
87}
88
89/// Where the initial OAuth token comes from.
90enum OAuthTokenSource {
91    /// A token provided directly (in-memory only, no store).
92    Token {
93        region: Region,
94        client_id: String,
95        token: Token,
96    },
97    /// A token loaded from a persistent store.
98    #[cfg(not(target_arch = "wasm32"))]
99    Store(ProfileStore),
100}
101
102/// Builder for [`DeviceSessionStrategy`].
103///
104/// Created via [`DeviceSessionStrategy::with_token`] or [`DeviceSessionStrategy::with_profile`].
105pub struct DeviceSessionStrategyBuilder {
106    source: OAuthTokenSource,
107    base_url_override: Option<url::Url>,
108}
109
110impl DeviceSessionStrategyBuilder {
111    /// Override the CTS base URL resolved for this strategy.
112    ///
113    /// Takes precedence over both the `CS_CTS_HOST` environment variable and
114    /// the token issuer / region-derived service discovery. Use it to point a
115    /// single strategy instance at a specific CTS host — e.g. a self-hosted
116    /// CTS, or a local mock auth server in development — without relying on the
117    /// process-wide `CS_CTS_HOST`, which would redirect every other CTS client
118    /// sharing the process.
119    pub fn base_url(mut self, url: url::Url) -> Self {
120        self.base_url_override = Some(url);
121        self
122    }
123
124    /// Build the [`DeviceSessionStrategy`].
125    ///
126    /// Resolves the base URL in priority order: an explicit [`base_url`]
127    /// override, then the `CS_CTS_HOST` environment variable, then the token
128    /// issuer.
129    ///
130    /// [`base_url`]: Self::base_url
131    pub fn build(self) -> Result<DeviceSessionStrategy, AuthError> {
132        let Self {
133            source,
134            base_url_override,
135        } = self;
136        match source {
137            OAuthTokenSource::Token {
138                region,
139                client_id,
140                token,
141            } => Self::build_from_token(region, client_id, token, base_url_override),
142            #[cfg(not(target_arch = "wasm32"))]
143            OAuthTokenSource::Store(store) => Self::build_from_store(store, base_url_override),
144        }
145    }
146
147    /// Build from a token supplied directly (in-memory only, no store).
148    fn build_from_token(
149        region: Region,
150        client_id: String,
151        mut token: Token,
152        base_url_override: Option<url::Url>,
153    ) -> Result<DeviceSessionStrategy, AuthError> {
154        let base_url = match base_url_override {
155            Some(url) => url,
156            None => {
157                crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?)
158            }
159        };
160        // Derive CRN from the explicit region parameter and the token's
161        // workspace claim. We can't use token.workspace_crn() here
162        // because set_region() hasn't been called on the token yet.
163        let crn = token
164            .workspace_id()
165            .map(|ws| Crn::new(region, ws))
166            .map_err(|e| {
167                warn!("Could not extract workspace CRN from token: {e}");
168                e
169            })
170            .ok();
171        let region_id = region.identifier();
172        let device_instance_id = token.device_instance_id().map(String::from);
173        token.set_region(&region_id);
174        token.set_client_id(&client_id);
175        let refresher = DeviceSessionRefresher::new(
176            None,
177            ensure_trailing_slash(base_url),
178            &client_id,
179            &region_id,
180            device_instance_id,
181        );
182        Ok(DeviceSessionStrategy {
183            crn,
184            inner: AutoRefresh::with_token(refresher, token),
185        })
186    }
187
188    /// Build from a token persisted in a [`ProfileStore`].
189    #[cfg(not(target_arch = "wasm32"))]
190    fn build_from_store(
191        store: ProfileStore,
192        base_url_override: Option<url::Url>,
193    ) -> Result<DeviceSessionStrategy, AuthError> {
194        let ws_store = store.current_workspace_store()?;
195        let token: Token = ws_store.load_profile()?;
196
197        let region_str = token
198            .region()
199            .ok_or(AuthError::NotAuthenticated)?
200            .to_string();
201        let client_id = token
202            .client_id()
203            .ok_or(AuthError::NotAuthenticated)?
204            .to_string();
205        let crn = token
206            .workspace_crn()
207            .map_err(|e| {
208                warn!("Could not extract workspace CRN from token: {e}");
209                e
210            })
211            .ok();
212        let device_instance_id = token.device_instance_id().map(String::from);
213
214        let base_url = match base_url_override {
215            Some(url) => url,
216            None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?),
217        };
218
219        let refresher = DeviceSessionRefresher::new(
220            Some(ws_store),
221            ensure_trailing_slash(base_url),
222            &client_id,
223            &region_str,
224            device_instance_id,
225        );
226        Ok(DeviceSessionStrategy {
227            crn,
228            inner: AutoRefresh::with_token(refresher, token),
229        })
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::test_support::{claims_with_workspace, jwt_token, raw_token};
237
238    fn base_url() -> url::Url {
239        "https://cts.example.com".parse().expect("valid url")
240    }
241
242    #[test]
243    fn build_from_token_derives_crn_from_the_jwt_workspace_claim() {
244        let region = Region::aws("ap-southeast-2").expect("valid region");
245        let token = jwt_token(claims_with_workspace("7366ITCXSAPCH5TN"));
246
247        let strategy = DeviceSessionStrategy::with_token(region, "my-client", token)
248            .base_url(base_url())
249            .build()
250            .expect("build should succeed for a valid token");
251
252        let crn = strategy
253            .workspace_crn()
254            .expect("CRN should be derived from the workspace claim")
255            .to_string();
256        assert!(crn.starts_with("crn:"), "unexpected CRN: {crn}");
257        assert!(
258            crn.contains("7366ITCXSAPCH5TN"),
259            "CRN should carry the token's workspace, got: {crn}"
260        );
261    }
262
263    #[test]
264    fn build_from_token_succeeds_without_a_crn_when_the_workspace_claim_is_absent() {
265        let region = Region::aws("ap-southeast-2").expect("valid region");
266        // A non-JWT access token: the workspace claim can't be decoded, so CRN
267        // derivation is skipped (it is best-effort) but the build still succeeds.
268        let token = raw_token("not-a-jwt");
269
270        let strategy = DeviceSessionStrategy::with_token(region, "my-client", token)
271            .base_url(base_url())
272            .build()
273            .expect("build should succeed even when the CRN can't be derived");
274
275        assert!(
276            strategy.workspace_crn().is_none(),
277            "CRN should be None when the token has no decodable workspace claim"
278        );
279    }
280}