stack_auth/
device_session_strategy.rs1use 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
11pub struct DeviceSessionStrategy {
37 crn: Option<Crn>,
38 inner: AutoRefresh<DeviceSessionRefresher>,
39}
40
41impl DeviceSessionStrategy {
42 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 #[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 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
89enum OAuthTokenSource {
91 Token {
93 region: Region,
94 client_id: String,
95 token: Token,
96 },
97 #[cfg(not(target_arch = "wasm32"))]
99 Store(ProfileStore),
100}
101
102pub struct DeviceSessionStrategyBuilder {
106 source: OAuthTokenSource,
107 base_url_override: Option<url::Url>,
108}
109
110impl DeviceSessionStrategyBuilder {
111 pub fn base_url(mut self, url: url::Url) -> Self {
120 self.base_url_override = Some(url);
121 self
122 }
123
124 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 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 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(®ion_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 ®ion_id,
180 device_instance_id,
181 );
182 Ok(DeviceSessionStrategy {
183 crn,
184 inner: AutoRefresh::with_token(refresher, token),
185 })
186 }
187
188 #[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 ®ion_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 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}