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(®ion_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 ®ion_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 ®ion_str,
195 device_instance_id,
196 );
197 Ok(DeviceSessionStrategy {
198 crn,
199 inner: AutoRefresh::with_token(refresher, token),
200 })
201 }
202 }
203 }
204}