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 base URL resolved by service discovery.
112    ///
113    /// Useful for pointing at a local or mock auth server during testing.
114    #[cfg(any(test, feature = "test-utils"))]
115    pub fn base_url(mut self, url: url::Url) -> Self {
116        self.base_url_override = Some(url);
117        self
118    }
119
120    /// Build the [`DeviceSessionStrategy`].
121    ///
122    /// Resolves the base URL via service discovery unless overridden with
123    /// `base_url` (available when the `test-utils` feature is enabled).
124    pub fn build(self) -> Result<DeviceSessionStrategy, AuthError> {
125        match self.source {
126            OAuthTokenSource::Token {
127                region,
128                client_id,
129                mut token,
130            } => {
131                let base_url = match self.base_url_override {
132                    Some(url) => url,
133                    None => crate::cts_base_url_from_env()?
134                        .unwrap_or(CtsServiceDiscovery::endpoint(region)?),
135                };
136                // Derive CRN from the explicit region parameter and the token's
137                // workspace claim. We can't use token.workspace_crn() here
138                // because set_region() hasn't been called on the token yet.
139                let crn = token
140                    .workspace_id()
141                    .map(|ws| Crn::new(region, ws))
142                    .map_err(|e| {
143                        warn!("Could not extract workspace CRN from token: {e}");
144                        e
145                    })
146                    .ok();
147                let region_id = region.identifier();
148                let device_instance_id = token.device_instance_id().map(String::from);
149                token.set_region(&region_id);
150                token.set_client_id(&client_id);
151                let refresher = DeviceSessionRefresher::new(
152                    None,
153                    ensure_trailing_slash(base_url),
154                    &client_id,
155                    &region_id,
156                    device_instance_id,
157                );
158                Ok(DeviceSessionStrategy {
159                    crn,
160                    inner: AutoRefresh::with_token(refresher, token),
161                })
162            }
163            #[cfg(not(target_arch = "wasm32"))]
164            OAuthTokenSource::Store(store) => {
165                let ws_store = store.current_workspace_store()?;
166                let token: Token = ws_store.load_profile()?;
167
168                let region_str = token
169                    .region()
170                    .ok_or(AuthError::NotAuthenticated)?
171                    .to_string();
172                let client_id = token
173                    .client_id()
174                    .ok_or(AuthError::NotAuthenticated)?
175                    .to_string();
176                let crn = token
177                    .workspace_crn()
178                    .map_err(|e| {
179                        warn!("Could not extract workspace CRN from token: {e}");
180                        e
181                    })
182                    .ok();
183                let device_instance_id = token.device_instance_id().map(String::from);
184
185                let base_url = match self.base_url_override {
186                    Some(url) => url,
187                    None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?),
188                };
189
190                let refresher = DeviceSessionRefresher::new(
191                    Some(ws_store),
192                    ensure_trailing_slash(base_url),
193                    &client_id,
194                    &region_str,
195                    device_instance_id,
196                );
197                Ok(DeviceSessionStrategy {
198                    crn,
199                    inner: AutoRefresh::with_token(refresher, token),
200                })
201            }
202        }
203    }
204}