stack_auth/device_code/mod.rs
1mod protocol;
2
3use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery};
4use url::Url;
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use std::path::PathBuf;
9
10use stack_profile::ProfileStore;
11
12use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token};
13use protocol::{
14 DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse,
15};
16
17#[cfg(test)]
18mod tests;
19
20/// Authenticates with CipherStash using the
21/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628).
22///
23/// This is the primary entry point for CLI and browserless authentication.
24/// Create a strategy with [`DeviceCodeStrategy::new`], then call
25/// [`begin`](DeviceCodeStrategy::begin) to start the flow.
26///
27/// # Example
28///
29/// ```
30/// use stack_auth::DeviceCodeStrategy;
31/// use cts_common::Region;
32///
33/// let region = Region::aws("ap-southeast-2").unwrap();
34/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap();
35/// ```
36pub struct DeviceCodeStrategy {
37 region: Region,
38 base_url: Url,
39 client_id: String,
40 profile_dir: Option<PathBuf>,
41 device_identity: Option<DeviceIdentity>,
42}
43
44impl DeviceCodeStrategy {
45 /// Create a new strategy for the given CipherStash region and OAuth client ID.
46 ///
47 /// The auth endpoint is resolved automatically via service discovery.
48 ///
49 /// # Example
50 ///
51 /// ```
52 /// use stack_auth::DeviceCodeStrategy;
53 /// use cts_common::Region;
54 ///
55 /// let strategy = DeviceCodeStrategy::new(
56 /// Region::aws("ap-southeast-2").unwrap(),
57 /// "my-client-id",
58 /// ).unwrap();
59 /// ```
60 pub fn new(region: Region, client_id: impl Into<String>) -> Result<Self, AuthError> {
61 Self::builder(region, client_id).build()
62 }
63
64 /// Return a builder for configuring a `DeviceCodeStrategy` before construction.
65 pub fn builder(region: Region, client_id: impl Into<String>) -> DeviceCodeStrategyBuilder {
66 DeviceCodeStrategyBuilder {
67 region,
68 client_id: client_id.into(),
69 base_url_override: None,
70 profile_dir: None,
71 device_identity: None,
72 }
73 }
74
75 /// Start the device code flow.
76 ///
77 /// Requests a device code from the CipherStash auth server and returns a
78 /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these
79 /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait
80 /// for authorization.
81 ///
82 /// # Errors
83 ///
84 /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized,
85 /// or [`AuthError::Request`] if the server is unreachable.
86 pub async fn begin(&self) -> Result<PendingDeviceCode, AuthError> {
87 let client = http_client();
88
89 let code_url = self.base_url.join("oauth/device/code")?;
90
91 tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code");
92
93 let device_instance_id = self
94 .device_identity
95 .as_ref()
96 .map(|d| d.device_instance_id.to_string());
97
98 let code_resp = client
99 .post(code_url)
100 .form(&DeviceCodeRequest {
101 client_id: &self.client_id,
102 device_instance_id: device_instance_id.as_deref(),
103 device_name: self
104 .device_identity
105 .as_ref()
106 .map(|d| d.device_name.as_str()),
107 })
108 .send()
109 .await?;
110
111 if !code_resp.status().is_success() {
112 let err: ErrorResponse = code_resp.json().await?;
113 tracing::debug!(error = %err.error, "device code request failed");
114 return Err(match err.error.as_str() {
115 "invalid_client" => AuthError::InvalidClient,
116 _ => AuthError::Server(err.error_description),
117 });
118 }
119
120 let code: DeviceCodeResponse = code_resp.json().await?;
121
122 let token_url = self.base_url.join("oauth/device/token")?;
123
124 tracing::debug!(
125 user_code = %code.user_code,
126 expires_in = code.expires_in,
127 "device code received"
128 );
129
130 Ok(PendingDeviceCode {
131 token_url,
132 region: self.region,
133 client_id: self.client_id.clone(),
134 device_code: code.device_code,
135 user_code: code.user_code,
136 verification_uri: code.verification_uri,
137 verification_uri_complete: code.verification_uri_complete,
138 expires_in: code.expires_in,
139 profile_dir: self.profile_dir.clone(),
140 device_identity: self.device_identity.clone(),
141 })
142 }
143}
144
145/// Builder for [`DeviceCodeStrategy`].
146///
147/// Created via [`DeviceCodeStrategy::builder`].
148pub struct DeviceCodeStrategyBuilder {
149 region: Region,
150 client_id: String,
151 base_url_override: Option<Url>,
152 profile_dir: Option<PathBuf>,
153 device_identity: Option<DeviceIdentity>,
154}
155
156impl DeviceCodeStrategyBuilder {
157 /// Override the auth-server base URL resolved for this flow.
158 ///
159 /// Takes precedence over both the `CS_CTS_HOST` environment variable and
160 /// region-derived service discovery. Use it to point a single flow at a
161 /// specific host — e.g. a self-hosted CTS, or a local mock auth server in
162 /// development — without relying on the process-wide `CS_CTS_HOST`, which
163 /// would redirect every other CTS client sharing the process.
164 pub fn base_url(mut self, url: Url) -> Self {
165 self.base_url_override = Some(url);
166 self
167 }
168
169 /// Override the profile directory used to persist the token.
170 ///
171 /// By default tokens are saved to `~/.cipherstash/auth.json`. Use this in
172 /// tests to redirect writes to a temporary directory.
173 #[cfg(any(test, feature = "test-utils"))]
174 pub fn profile_dir(mut self, dir: impl Into<PathBuf>) -> Self {
175 self.profile_dir = Some(dir.into());
176 self
177 }
178
179 /// Set the device identity for this strategy.
180 ///
181 /// When set, the device instance ID and name are sent to the auth server
182 /// during the device code flow and persisted in the token.
183 pub fn device_identity(mut self, identity: DeviceIdentity) -> Self {
184 self.device_identity = Some(identity);
185 self
186 }
187
188 /// Build the [`DeviceCodeStrategy`].
189 ///
190 /// Resolves the base URL in priority order: an explicit [`base_url`]
191 /// override, then the `CS_CTS_HOST` environment variable, then service
192 /// discovery using the region.
193 ///
194 /// [`base_url`]: Self::base_url
195 pub fn build(self) -> Result<DeviceCodeStrategy, AuthError> {
196 let base_url = match self.base_url_override {
197 Some(url) => url,
198 None => crate::cts_base_url_from_env()?
199 .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?),
200 };
201 Ok(DeviceCodeStrategy {
202 region: self.region,
203 base_url: ensure_trailing_slash(base_url),
204 client_id: self.client_id,
205 profile_dir: self.profile_dir,
206 device_identity: self.device_identity,
207 })
208 }
209}
210
211/// A device code flow that is waiting for the user to authorize.
212///
213/// Returned by [`DeviceCodeStrategy::begin`]. Display the
214/// [`user_code`](Self::user_code) and
215/// [`verification_uri_complete`](Self::verification_uri_complete) to the user
216/// (or call [`open_in_browser`](Self::open_in_browser)), then call
217/// [`poll_for_token`](Self::poll_for_token) to wait for authorization.
218///
219/// # Example
220///
221/// ```no_run
222/// # use stack_auth::DeviceCodeStrategy;
223/// # use cts_common::Region;
224/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
225/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?;
226/// let pending = strategy.begin().await?;
227///
228/// println!("Go to: {}", pending.verification_uri_complete());
229/// println!("Enter code: {}", pending.user_code());
230///
231/// let token = pending.poll_for_token().await?;
232/// # Ok(())
233/// # }
234/// ```
235#[derive(Debug)]
236pub struct PendingDeviceCode {
237 token_url: Url,
238 region: Region,
239 client_id: String,
240 device_code: DeviceCode,
241 /// The short code the user must enter to authorize this device.
242 user_code: String,
243 /// The base verification URI (without the user code embedded).
244 verification_uri: String,
245 /// The full verification URI with the user code pre-filled.
246 verification_uri_complete: String,
247 /// How many seconds the device code remains valid.
248 expires_in: u64,
249 /// Profile directory override. Falls back to `~/.cipherstash`.
250 profile_dir: Option<PathBuf>,
251 /// Device identity to associate with the token.
252 device_identity: Option<DeviceIdentity>,
253}
254
255impl PendingDeviceCode {
256 /// The short code the user must enter to authorize this device.
257 pub fn user_code(&self) -> &str {
258 &self.user_code
259 }
260
261 /// The base verification URI (without the user code embedded).
262 pub fn verification_uri(&self) -> &str {
263 &self.verification_uri
264 }
265
266 /// The full verification URI with the user code pre-filled.
267 pub fn verification_uri_complete(&self) -> &str {
268 &self.verification_uri_complete
269 }
270
271 /// How many seconds the device code remains valid.
272 pub fn expires_in(&self) -> u64 {
273 self.expires_in
274 }
275
276 /// Open the verification URI in the user's default browser.
277 ///
278 /// Returns `true` if the browser was opened successfully.
279 pub fn open_in_browser(&self) -> bool {
280 open::that(&self.verification_uri_complete).is_ok()
281 }
282
283 /// Poll the auth server until the user authorizes (or the code expires).
284 ///
285 /// This method consumes `self` and blocks asynchronously, polling at a
286 /// server-controlled interval (starting at 5 seconds). It returns a
287 /// [`Token`] on success.
288 ///
289 /// # Errors
290 ///
291 /// - [`AuthError::AccessDenied`] — the user rejected the request.
292 /// - [`AuthError::TokenExpired`] — the device code expired before the user
293 /// authorized.
294 /// - [`AuthError::Request`] — a network error occurred while polling.
295 pub async fn poll_for_token(self) -> Result<Token, AuthError> {
296 let client = http_client();
297 let mut interval = tokio::time::Duration::from_secs(5);
298 let deadline =
299 tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in);
300
301 tracing::debug!(
302 url = %self.token_url,
303 expires_in = self.expires_in,
304 "polling for token"
305 );
306
307 loop {
308 if tokio::time::Instant::now() >= deadline {
309 tracing::debug!("device code expired while polling");
310 return Err(AuthError::TokenExpired);
311 }
312
313 let resp = client
314 .post(self.token_url.clone())
315 .form(&TokenRequest {
316 client_id: &self.client_id,
317 device_code: &self.device_code,
318 grant_type: "urn:ietf:params:oauth:grant-type:device_code",
319 })
320 .send()
321 .await?;
322
323 if resp.status().is_success() {
324 tracing::debug!("token received");
325 let token_resp: TokenResponse = resp.json().await?;
326 let now = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .unwrap_or_default()
329 .as_secs();
330 let mut token = Token {
331 access_token: token_resp.access_token,
332 token_type: token_resp.token_type,
333 expires_at: now + token_resp.expires_in,
334 refresh_token: token_resp.refresh_token,
335 region: None,
336 client_id: None,
337 device_instance_id: None,
338 };
339 token.set_region(self.region.identifier());
340 token.set_client_id(&self.client_id);
341 if let Some(ref identity) = self.device_identity {
342 token.set_device_instance_id(identity.device_instance_id.to_string());
343 }
344
345 let store = match &self.profile_dir {
346 Some(dir) => ProfileStore::new(dir),
347 None => ProfileStore::resolve(None)?,
348 };
349 let workspace_id = token.workspace_id()?;
350 store.init_workspace(workspace_id.as_str())?;
351 store
352 .workspace_store(workspace_id.as_str())?
353 .save_profile(&token)?;
354 tracing::debug!(
355 workspace = workspace_id.as_str(),
356 "token saved to workspace directory"
357 );
358
359 return Ok(token);
360 }
361
362 let err: ErrorResponse = resp.json().await?;
363 match err.error.as_str() {
364 "authorization_pending" => {
365 tracing::debug!("authorization pending, retrying");
366 }
367 "slow_down" => {
368 interval += tokio::time::Duration::from_secs(5);
369 tracing::debug!(interval_secs = interval.as_secs(), "slowing down");
370 }
371 "expired_token" => return Err(AuthError::TokenExpired),
372 "access_denied" => return Err(AuthError::AccessDenied),
373 "invalid_grant" => return Err(AuthError::InvalidGrant),
374 "invalid_client" => return Err(AuthError::InvalidClient),
375 _ => return Err(AuthError::Server(err.error_description)),
376 }
377
378 tokio::time::sleep(interval).await;
379 }
380 }
381}