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(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
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 oauth_refresher;
39mod oauth_strategy;
40mod refresher;
41mod service_token;
42mod token;
43
44// Filesystem-backed device identity and the interactive device-code flow are
45// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`)
46// and the device-code flow launches a browser via `open::that`. Wasm consumers
47// use `OAuthStrategy::with_token` or `AccessKeyStrategy`.
48#[cfg(not(target_arch = "wasm32"))]
49mod device_client;
50#[cfg(not(target_arch = "wasm32"))]
51mod device_code;
52
53#[cfg(any(test, feature = "test-utils"))]
54mod static_token_strategy;
55
56pub use access_key::{AccessKey, InvalidAccessKey};
57pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
58pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
59pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder};
60pub use service_token::ServiceToken;
61#[cfg(any(test, feature = "test-utils"))]
62pub use static_token_strategy::StaticTokenStrategy;
63pub use token::Token;
64
65#[cfg(not(target_arch = "wasm32"))]
66pub use device_client::{bind_client_device, DeviceClientError};
67#[cfg(not(target_arch = "wasm32"))]
68pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
69
70// Re-exports from stack-profile for backward compatibility.
71#[cfg(not(target_arch = "wasm32"))]
72pub use stack_profile::DeviceIdentity;
73
74/// A strategy for obtaining access tokens.
75///
76/// Implementations handle all details of authentication, token caching, and
77/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
78/// they need a valid token.
79///
80/// The trait is designed to be implemented for `&T`, so that callers can use
81/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy.
82///
83/// # Token refresh
84///
85/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`],
86/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
87/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
88/// behaves under concurrent access.
89///
90/// ## Expiry vs usability
91///
92/// A token has two time thresholds:
93///
94/// - **Expired** — the token is within **90 seconds** of its `expires_at`
95/// timestamp. This triggers a preemptive refresh attempt.
96/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
97/// A token can be "expired" (in the preemptive sense) but still "usable"
98/// (the server will still accept it).
99///
100/// ## Concurrent refresh strategies
101///
102/// The gap between "expired" and "unusable" enables two refresh modes:
103///
104/// 1. **Expiring but still usable** — The first caller triggers a background
105/// refresh. Concurrent callers receive the current (still-valid) token
106/// immediately without blocking.
107/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
108/// callers wait until the refresh completes, then all receive the new token.
109///
110/// Only one refresh runs at a time, regardless of how many callers request a
111/// token concurrently.
112///
113/// ## Flow diagram
114///
115/// ```mermaid
116/// flowchart TD
117/// Start["get_token()"] --> Lock["Acquire lock"]
118/// Lock --> Cached{Token cached?}
119/// Cached -- No --> InitAuth["Authenticate
120/// (lock held)"]
121/// InitAuth -- OK --> ReturnNew["Return new token"]
122/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
123/// InitAuth -- Err --> ErrAuth["Return error"]
124/// Cached -- Yes --> CheckRefresh{Expired?}
125///
126/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
127///
128/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
129/// InProgress -- Yes --> WaitOrReturn["Return token if usable,
130/// else wait for refresh"]
131/// WaitOrReturn -- OK --> ReturnOk
132/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
133///
134/// InProgress -- No --> HasCred{Refresh credential?}
135/// HasCred -- None --> CheckUsable["Return token if usable,
136/// else TokenExpired"]
137///
138/// HasCred -- Yes --> Usable{Still usable?}
139///
140/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
141/// (lock released)"]
142/// NonBlocking --> ReturnOld["Return current token"]
143///
144/// Usable -- "No (fully expired)" --> Blocking["Refresh
145/// (lock held)"]
146/// Blocking -- OK --> ReturnNew2["Return new token"]
147/// Blocking -- Err --> ErrExpired["TokenExpired"]
148/// ```
149#[cfg_attr(doc, aquamarine::aquamarine)]
150#[cfg(not(target_arch = "wasm32"))]
151pub trait AuthStrategy: Send {
152 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
153 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
154}
155
156/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because
157/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are
158/// single-threaded.
159#[cfg(target_arch = "wasm32")]
160pub trait AuthStrategy {
161 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
162 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
163}
164
165/// A sensitive token string that is zeroized on drop and hidden from debug output.
166///
167/// `SecretToken` wraps a `String` and enforces two invariants:
168///
169/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
170/// the token goes out of scope, preventing it from lingering in memory.
171/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
172/// the actual value, so tokens won't leak into logs or error messages.
173///
174/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
175/// loaded from configuration or an environment variable).
176#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
177#[serde(transparent)]
178pub struct SecretToken(String);
179
180impl SecretToken {
181 /// Create a new `SecretToken` from a string value.
182 pub fn new(value: impl Into<String>) -> Self {
183 Self(value.into())
184 }
185
186 /// Expose the inner token string for FFI boundaries.
187 pub fn as_str(&self) -> &str {
188 &self.0
189 }
190}
191
192/// Errors that can occur during an authentication flow.
193#[derive(Debug, thiserror::Error, miette::Diagnostic)]
194#[non_exhaustive]
195pub enum AuthError {
196 /// The HTTP request to the auth server failed (network error, timeout, etc.).
197 #[error("HTTP request failed: {0}")]
198 Request(#[from] reqwest::Error),
199 /// The user denied the authorization request.
200 #[error("Authorization was denied")]
201 AccessDenied,
202 /// The grant type was rejected by the server.
203 #[error("Invalid grant")]
204 InvalidGrant,
205 /// The client ID is not recognized.
206 #[error("Invalid client")]
207 InvalidClient,
208 /// A URL could not be parsed.
209 #[error("Invalid URL: {0}")]
210 InvalidUrl(#[from] url::ParseError),
211 /// The requested region is not supported.
212 #[error("Unsupported region: {0}")]
213 Region(#[from] cts_common::RegionError),
214 /// The workspace CRN could not be parsed.
215 #[error("Invalid workspace CRN: {0}")]
216 InvalidCrn(cts_common::InvalidCrn),
217 /// An access key was provided but the workspace CRN is missing.
218 ///
219 /// Set the `CS_WORKSPACE_CRN` environment variable or call
220 /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
221 #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
222 MissingWorkspaceCrn,
223 /// No credentials are available (e.g. not logged in, no access key configured).
224 #[error("Not authenticated")]
225 NotAuthenticated,
226 /// A token (access token or device code) has expired.
227 #[error("Token expired")]
228 TokenExpired,
229 /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
230 #[error("Invalid access key: {0}")]
231 InvalidAccessKey(#[from] access_key::InvalidAccessKey),
232 /// The JWT could not be decoded or its claims are malformed.
233 #[error("Invalid token: {0}")]
234 InvalidToken(String),
235 /// An unexpected error was returned by the auth server.
236 #[error("Server error: {0}")]
237 Server(String),
238 /// A token store operation failed.
239 #[cfg(not(target_arch = "wasm32"))]
240 #[error("Token store error: {0}")]
241 Store(#[from] stack_profile::ProfileError),
242}
243
244impl AuthError {
245 /// Stable machine-readable identifier for surfacing across FFI boundaries
246 /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather
247 /// than `code` to avoid colliding with `miette::Diagnostic::code`, which
248 /// is inherited via `#[derive(Diagnostic)]`.
249 pub fn error_code(&self) -> &'static str {
250 match self {
251 Self::Request(_) => "REQUEST_ERROR",
252 Self::AccessDenied => "ACCESS_DENIED",
253 Self::TokenExpired => "EXPIRED_TOKEN",
254 Self::InvalidGrant => "INVALID_GRANT",
255 Self::InvalidClient => "INVALID_CLIENT",
256 Self::InvalidUrl(_) => "INVALID_URL",
257 Self::Region(_) => "INVALID_REGION",
258 Self::InvalidToken(_) => "INVALID_TOKEN",
259 Self::Server(_) => "SERVER_ERROR",
260 Self::NotAuthenticated => "NOT_AUTHENTICATED",
261 Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN",
262 Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY",
263 Self::InvalidCrn(_) => "INVALID_CRN",
264 #[cfg(not(target_arch = "wasm32"))]
265 Self::Store(_) => "STORE_ERROR",
266 }
267 }
268}
269
270impl From<Infallible> for AuthError {
271 fn from(never: Infallible) -> Self {
272 match never {}
273 }
274}
275
276/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
277///
278/// Returns `Ok(None)` if the variable is not set or empty.
279/// Returns `Ok(Some(url))` if the variable is set and valid.
280/// Returns `Err(_)` if the variable is set but not a valid URL.
281pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
282 match std::env::var("CS_CTS_HOST") {
283 Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
284 _ => Ok(None),
285 }
286}
287
288/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
289/// appends to the path rather than replacing the last segment.
290pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
291 if !url.path().ends_with('/') {
292 url.set_path(&format!("{}/", url.path()));
293 }
294 url
295}
296
297/// Decode a JWT payload by splitting on `.`, base64-decoding the middle
298/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken`
299/// (which pulls `ring`). Signatures are not verified — same posture as the
300/// native path, which calls `insecure_disable_signature_validation()`.
301#[cfg(target_arch = "wasm32")]
302pub(crate) fn decode_jwt_payload_wasm<C>(token: &str) -> Result<C, AuthError>
303where
304 C: serde::de::DeserializeOwned,
305{
306 use base64::Engine;
307 let segments: Vec<&str> = token.split('.').collect();
308 if segments.len() != 3 {
309 return Err(AuthError::InvalidToken(
310 "JWT must have three segments".to_string(),
311 ));
312 }
313 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
314 .decode(segments[1])
315 .map_err(|e| AuthError::InvalidToken(format!("base64 decode failed: {e}")))?;
316 serde_json::from_slice(&payload)
317 .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}")))
318}
319
320/// Create a [`reqwest::Client`] with standard timeouts.
321///
322/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
323/// does not auto-advance time past the connect timeout before the mock server
324/// can respond. On wasm32, reqwest's fetch backend doesn't expose
325/// `connect_timeout`/`pool_*` — the host runtime owns those concerns.
326#[cfg(any(test, feature = "test-utils"))]
327pub(crate) fn http_client() -> reqwest::Client {
328 reqwest::Client::builder()
329 .build()
330 .unwrap_or_else(|_| reqwest::Client::new())
331}
332
333#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
334pub(crate) fn http_client() -> reqwest::Client {
335 reqwest::Client::builder()
336 .connect_timeout(Duration::from_secs(10))
337 .timeout(Duration::from_secs(30))
338 .pool_idle_timeout(Duration::from_secs(5))
339 .pool_max_idle_per_host(10)
340 .build()
341 .unwrap_or_else(|_| reqwest::Client::new())
342}
343
344#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
345pub(crate) fn http_client() -> reqwest::Client {
346 // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned
347 // by the runtime, so `ClientBuilder` doesn't expose them here.
348 reqwest::Client::builder()
349 .build()
350 .unwrap_or_else(|_| reqwest::Client::new())
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn auth_error_code_known_variants() {
359 assert_eq!(AuthError::AccessDenied.error_code(), "ACCESS_DENIED");
360 assert_eq!(AuthError::TokenExpired.error_code(), "EXPIRED_TOKEN");
361 assert_eq!(AuthError::InvalidGrant.error_code(), "INVALID_GRANT");
362 assert_eq!(AuthError::InvalidClient.error_code(), "INVALID_CLIENT");
363 assert_eq!(
364 AuthError::NotAuthenticated.error_code(),
365 "NOT_AUTHENTICATED"
366 );
367 assert_eq!(
368 AuthError::MissingWorkspaceCrn.error_code(),
369 "MISSING_WORKSPACE_CRN"
370 );
371 assert_eq!(AuthError::Server("x".into()).error_code(), "SERVER_ERROR");
372 assert_eq!(
373 AuthError::InvalidToken("malformed".into()).error_code(),
374 "INVALID_TOKEN"
375 );
376 }
377}