stack_auth/lib.rs
1#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")]
2#![doc = include_str!("../README.md")]
3// Security lints
4#![deny(unsafe_code)]
5#![warn(clippy::unwrap_used)]
6#![warn(clippy::expect_used)]
7#![warn(clippy::panic)]
8// Prevent mem::forget from bypassing ZeroizeOnDrop
9#![warn(clippy::mem_forget)]
10// Prevent accidental data leaks via output
11#![warn(clippy::print_stdout)]
12#![warn(clippy::print_stderr)]
13#![warn(clippy::dbg_macro)]
14// Code quality
15#![warn(unreachable_pub)]
16#![warn(unused_results)]
17#![warn(clippy::todo)]
18#![warn(clippy::unimplemented)]
19// Relax in tests
20#![cfg_attr(test, allow(clippy::unwrap_used))]
21#![cfg_attr(test, allow(clippy::expect_used))]
22#![cfg_attr(test, allow(clippy::panic))]
23#![cfg_attr(test, allow(unused_results))]
24
25use std::convert::Infallible;
26use std::future::Future;
27#[cfg(not(any(test, feature = "test-utils")))]
28use std::time::Duration;
29
30use vitaminc::protected::OpaqueDebug;
31use zeroize::ZeroizeOnDrop;
32
33mod access_key;
34mod access_key_refresher;
35mod access_key_strategy;
36mod auto_refresh;
37mod auto_strategy;
38mod device_client;
39mod device_code;
40mod oauth_refresher;
41mod oauth_strategy;
42mod refresher;
43mod service_token;
44mod token;
45
46#[cfg(any(test, feature = "test-utils"))]
47mod static_token_strategy;
48
49pub use access_key::{AccessKey, InvalidAccessKey};
50pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
51pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
52pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
53pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder};
54pub use service_token::ServiceToken;
55#[cfg(any(test, feature = "test-utils"))]
56pub use static_token_strategy::StaticTokenStrategy;
57pub use token::Token;
58
59pub use device_client::{bind_client_device, DeviceClientError};
60
61// Re-exports from stack-profile for backward compatibility.
62pub use stack_profile::DeviceIdentity;
63
64/// A strategy for obtaining access tokens.
65///
66/// Implementations handle all details of authentication, token caching, and
67/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
68/// they need a valid token.
69///
70/// The trait is designed to be implemented for `&T`, so that callers can use
71/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy.
72///
73/// # Token refresh
74///
75/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`],
76/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
77/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
78/// behaves under concurrent access.
79///
80/// ## Expiry vs usability
81///
82/// A token has two time thresholds:
83///
84/// - **Expired** — the token is within **90 seconds** of its `expires_at`
85/// timestamp. This triggers a preemptive refresh attempt.
86/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
87/// A token can be "expired" (in the preemptive sense) but still "usable"
88/// (the server will still accept it).
89///
90/// ## Concurrent refresh strategies
91///
92/// The gap between "expired" and "unusable" enables two refresh modes:
93///
94/// 1. **Expiring but still usable** — The first caller triggers a background
95/// refresh. Concurrent callers receive the current (still-valid) token
96/// immediately without blocking.
97/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
98/// callers wait until the refresh completes, then all receive the new token.
99///
100/// Only one refresh runs at a time, regardless of how many callers request a
101/// token concurrently.
102///
103/// ## Flow diagram
104///
105/// ```mermaid
106/// flowchart TD
107/// Start["get_token()"] --> Lock["Acquire lock"]
108/// Lock --> Cached{Token cached?}
109/// Cached -- No --> InitAuth["Authenticate
110/// (lock held)"]
111/// InitAuth -- OK --> ReturnNew["Return new token"]
112/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
113/// InitAuth -- Err --> ErrAuth["Return error"]
114/// Cached -- Yes --> CheckRefresh{Expired?}
115///
116/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
117///
118/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
119/// InProgress -- Yes --> WaitOrReturn["Return token if usable,
120/// else wait for refresh"]
121/// WaitOrReturn -- OK --> ReturnOk
122/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
123///
124/// InProgress -- No --> HasCred{Refresh credential?}
125/// HasCred -- None --> CheckUsable["Return token if usable,
126/// else TokenExpired"]
127///
128/// HasCred -- Yes --> Usable{Still usable?}
129///
130/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
131/// (lock released)"]
132/// NonBlocking --> ReturnOld["Return current token"]
133///
134/// Usable -- "No (fully expired)" --> Blocking["Refresh
135/// (lock held)"]
136/// Blocking -- OK --> ReturnNew2["Return new token"]
137/// Blocking -- Err --> ErrExpired["TokenExpired"]
138/// ```
139#[cfg_attr(doc, aquamarine::aquamarine)]
140pub trait AuthStrategy: Send {
141 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
142 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
143}
144
145/// A sensitive token string that is zeroized on drop and hidden from debug output.
146///
147/// `SecretToken` wraps a `String` and enforces two invariants:
148///
149/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
150/// the token goes out of scope, preventing it from lingering in memory.
151/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
152/// the actual value, so tokens won't leak into logs or error messages.
153///
154/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
155/// loaded from configuration or an environment variable).
156#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
157#[serde(transparent)]
158pub struct SecretToken(String);
159
160impl SecretToken {
161 /// Create a new `SecretToken` from a string value.
162 pub fn new(value: impl Into<String>) -> Self {
163 Self(value.into())
164 }
165
166 /// Expose the inner token string for FFI boundaries.
167 pub fn as_str(&self) -> &str {
168 &self.0
169 }
170}
171
172/// Errors that can occur during an authentication flow.
173#[derive(Debug, thiserror::Error, miette::Diagnostic)]
174#[non_exhaustive]
175pub enum AuthError {
176 /// The HTTP request to the auth server failed (network error, timeout, etc.).
177 #[error("HTTP request failed: {0}")]
178 Request(#[from] reqwest::Error),
179 /// The user denied the authorization request.
180 #[error("Authorization was denied")]
181 AccessDenied,
182 /// The grant type was rejected by the server.
183 #[error("Invalid grant")]
184 InvalidGrant,
185 /// The client ID is not recognized.
186 #[error("Invalid client")]
187 InvalidClient,
188 /// A URL could not be parsed.
189 #[error("Invalid URL: {0}")]
190 InvalidUrl(#[from] url::ParseError),
191 /// The requested region is not supported.
192 #[error("Unsupported region: {0}")]
193 Region(#[from] cts_common::RegionError),
194 /// The workspace CRN could not be parsed.
195 #[error("Invalid workspace CRN: {0}")]
196 InvalidCrn(cts_common::InvalidCrn),
197 /// An access key was provided but the workspace CRN is missing.
198 ///
199 /// Set the `CS_WORKSPACE_CRN` environment variable or call
200 /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
201 #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
202 MissingWorkspaceCrn,
203 /// No credentials are available (e.g. not logged in, no access key configured).
204 #[error("Not authenticated")]
205 NotAuthenticated,
206 /// A token (access token or device code) has expired.
207 #[error("Token expired")]
208 TokenExpired,
209 /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
210 #[error("Invalid access key: {0}")]
211 InvalidAccessKey(#[from] access_key::InvalidAccessKey),
212 /// The JWT could not be decoded or its claims are malformed.
213 #[error("Invalid token: {0}")]
214 InvalidToken(String),
215 /// An unexpected error was returned by the auth server.
216 #[error("Server error: {0}")]
217 Server(String),
218 /// A token store operation failed.
219 #[error("Token store error: {0}")]
220 Store(#[from] stack_profile::ProfileError),
221}
222
223impl From<Infallible> for AuthError {
224 fn from(never: Infallible) -> Self {
225 match never {}
226 }
227}
228
229/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
230///
231/// Returns `Ok(None)` if the variable is not set or empty.
232/// Returns `Ok(Some(url))` if the variable is set and valid.
233/// Returns `Err(_)` if the variable is set but not a valid URL.
234pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
235 match std::env::var("CS_CTS_HOST") {
236 Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
237 _ => Ok(None),
238 }
239}
240
241/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
242/// appends to the path rather than replacing the last segment.
243pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
244 if !url.path().ends_with('/') {
245 url.set_path(&format!("{}/", url.path()));
246 }
247 url
248}
249
250/// Create a [`reqwest::Client`] with standard timeouts.
251///
252/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
253/// does not auto-advance time past the connect timeout before the mock server
254/// can respond.
255pub(crate) fn http_client() -> reqwest::Client {
256 #[cfg(any(test, feature = "test-utils"))]
257 {
258 reqwest::Client::builder()
259 .pool_max_idle_per_host(10)
260 .build()
261 .unwrap_or_else(|_| reqwest::Client::new())
262 }
263 #[cfg(not(any(test, feature = "test-utils")))]
264 {
265 reqwest::Client::builder()
266 .connect_timeout(Duration::from_secs(10))
267 .timeout(Duration::from_secs(30))
268 .pool_idle_timeout(Duration::from_secs(5))
269 .pool_max_idle_per_host(10)
270 .build()
271 .unwrap_or_else(|_| reqwest::Client::new())
272 }
273}