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